mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-11-13 20:33:35 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d10b8fc4a | ||
|
|
14bec248f1 | ||
|
|
ff57368f37 | ||
|
|
f589397008 | ||
|
|
fa4f6b58f3 | ||
|
|
1acfd2ae54 | ||
|
|
91717e3b96 | ||
|
|
282d8b8bf6 | ||
|
|
fb4c3ba984 | ||
|
|
72a1c656f9 | ||
|
|
d22c468215 | ||
|
|
c654de4630 | ||
|
|
aef33df8d3 | ||
|
|
63a883e595 | ||
|
|
b8b069eca6 | ||
|
|
0fd0483021 | ||
|
|
8463b8426a | ||
|
|
e874d31e64 | ||
|
|
557f56ff21 | ||
|
|
7f296a5135 | ||
|
|
b5134a3287 | ||
|
|
8fb7a856d5 | ||
|
|
ad8b235058 | ||
|
|
186733f6af | ||
|
|
1e5443f054 | ||
|
|
18e32410ad | ||
|
|
5077f87e0c | ||
|
|
608d3ddde8 | ||
|
|
4c27a7b68e | ||
|
|
a3b150d049 | ||
|
|
088c4f542f | ||
|
|
6acb4b9005 | ||
|
|
8781930ced | ||
|
|
4236eef5c4 | ||
|
|
b14224a6d8 | ||
|
|
a6128a6c98 | ||
|
|
8a2a45751e | ||
|
|
83e9a82f8f | ||
|
|
bf4f74cb93 | ||
|
|
1e88ff01e7 | ||
|
|
5ec4a8a46e | ||
|
|
99e2c45482 | ||
|
|
3c5d191c33 | ||
|
|
b67cbffbf9 | ||
|
|
eeaa408a2d | ||
|
|
eba1909c9c | ||
|
|
fbfb24c264 | ||
|
|
2440fb37c4 | ||
|
|
65c075283c | ||
|
|
198b2c27b8 | ||
|
|
d6398e54dd | ||
|
|
0571747fab | ||
|
|
ec2738cb0f | ||
|
|
a7ebb3e1af | ||
|
|
dfce15a74c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -19,6 +19,8 @@ on:
|
||||
- server/**
|
||||
- index.js
|
||||
- package.json
|
||||
- tsconfig.server.json
|
||||
- Dockerfile
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- test/**
|
||||
- index.js
|
||||
- package.json
|
||||
- tsconfig.server.json
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,10 +24,10 @@ jobs:
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: install pkg (using yao-pkg fork for targeting node20)
|
||||
- name: install pkg (using yao-pkg fork for targeting node24)
|
||||
run: npm install -g @yao-pkg/pkg
|
||||
|
||||
- name: get client dependencies
|
||||
@@ -37,11 +38,11 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
- name: get server dependencies and compile server
|
||||
run: npm ci && npm run build:server && npm prune --omit=dev
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
||||
run: pkg --config package.json -t node24-linux-x64 -o audiobookshelf dist-server/index.js
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
# Install Redocly CLI
|
||||
|
||||
@@ -28,11 +28,14 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
@@ -11,6 +11,7 @@
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/dist-server/
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
+16
-7
@@ -2,15 +2,23 @@ ARG NUSQLITE3_DIR="/usr/local/lib/nusqlite3"
|
||||
ARG NUSQLITE3_PATH="${NUSQLITE3_DIR}/libnusqlite3.so"
|
||||
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:20-alpine AS build-client
|
||||
FROM node:24-alpine AS build-client
|
||||
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM node:20-alpine AS build-server
|
||||
### STAGE 1: Compile server on the builder CPU (avoid QEMU SIGILL from tsc on arm64) ###
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS compile-server
|
||||
|
||||
WORKDIR /server
|
||||
COPY index.js package* tsconfig.server.json /server
|
||||
COPY /server /server/server
|
||||
RUN npm ci --include=dev --ignore-scripts && npm run build:server
|
||||
|
||||
### STAGE 2: Install native server deps for the target arch ###
|
||||
FROM node:24-alpine AS build-server
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG TARGETPLATFORM
|
||||
@@ -27,6 +35,7 @@ RUN apk add --no-cache --update \
|
||||
WORKDIR /server
|
||||
COPY index.js package* /server
|
||||
COPY /server /server/server
|
||||
COPY --from=compile-server /server/dist-server /server/dist-server
|
||||
|
||||
RUN case "$TARGETPLATFORM" in \
|
||||
"linux/amd64") \
|
||||
@@ -38,10 +47,10 @@ RUN case "$TARGETPLATFORM" in \
|
||||
unzip /tmp/library.zip -d $NUSQLITE3_DIR && \
|
||||
rm /tmp/library.zip
|
||||
|
||||
RUN npm ci --only=production
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
### STAGE 2: Create minimal runtime image ###
|
||||
FROM node:20-alpine
|
||||
### STAGE 3: Create minimal runtime image ###
|
||||
FROM node:24-alpine
|
||||
|
||||
ARG NUSQLITE3_DIR
|
||||
ARG NUSQLITE3_PATH
|
||||
@@ -70,4 +79,4 @@ ENV NUSQLITE3_DIR=${NUSQLITE3_DIR}
|
||||
ENV NUSQLITE3_PATH=${NUSQLITE3_PATH}
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
CMD ["node", "dist-server/index.js"]
|
||||
+4
-3
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ module.exports = {
|
||||
proxy,
|
||||
|
||||
io: {
|
||||
server: false,
|
||||
sockets: [
|
||||
{
|
||||
name: 'dev',
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.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
@@ -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",
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"HeaderStatsLargestItems": "Největší položky",
|
||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||
"HeaderStatsMinutesListeningChart": "Poslech za posledních 7 dní (min)",
|
||||
"HeaderStatsRecentSessions": "Poslední relace",
|
||||
"HeaderStatsRecentSessions": "Nedávné relace",
|
||||
"HeaderStatsTop10Authors": "Top 10 autorů",
|
||||
"HeaderStatsTop5Genres": "Top 5 žánrů",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
@@ -1135,12 +1135,12 @@
|
||||
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
|
||||
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
|
||||
"ToastServerSettingsUpdateSuccess": "Nastavení serveru aktualizováno",
|
||||
"ToastSessionCloseFailed": "Chyba při ukončení",
|
||||
"ToastSessionCloseFailed": "Nepodařilo se ukončit relaci",
|
||||
"ToastSessionDeleteFailed": "Nepodařilo se smazat relaci",
|
||||
"ToastSessionDeleteSuccess": "Relace smazána",
|
||||
"ToastSleepTimerDone": "Uspání knížky ... zZzzZz",
|
||||
"ToastSlugMustChange": "Slug obsahuje chybné znaky",
|
||||
"ToastSlugRequired": "Slug (URL) je vyžadována",
|
||||
"ToastSlugRequired": "Slug (URL) je vyžadován",
|
||||
"ToastSocketConnected": "Socket připojen",
|
||||
"ToastSocketDisconnected": "Socket odpojen",
|
||||
"ToastSocketFailedToConnect": "Socket se nepodařilo připojit",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Más recientes",
|
||||
"ButtonLibrary": "Biblioteca",
|
||||
"ButtonLogout": "Cerrar Sesión",
|
||||
"ButtonLogoutAllDevices": "Cerrar sesión en todos los dispositivos",
|
||||
"ButtonLookup": "Averiguar",
|
||||
"ButtonManageTracks": "Gestionar pistas",
|
||||
"ButtonMapChapterTitles": "Asignar Títulos a Capítulos",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Planificador de auto‐descargas de episodios",
|
||||
"HeaderScheduleLibraryScans": "Planificar Auto‐Escaneo de Biblioteca",
|
||||
"HeaderSession": "Sesión",
|
||||
"HeaderSessions": "Sesiones",
|
||||
"HeaderSetBackupSchedule": "Establecer Planificación de Respaldo",
|
||||
"HeaderSettings": "Ajustes",
|
||||
"HeaderSettingsDisplay": "Interfaz",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Cada hora",
|
||||
"LabelIntervalEveryMinute": "Cada minuto",
|
||||
"LabelInvert": "Invertir",
|
||||
"LabelIpAddress": "Dirección IP",
|
||||
"LabelItem": "Elemento",
|
||||
"LabelJumpBackwardAmount": "Cantidad de saltos hacia atrás",
|
||||
"LabelJumpForwardAmount": "Cantidad de saltos hacia adelante",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "¿Confirma que quiere eliminar esta sesión?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "¿Confirma que quiere incrustar metadatos en {0} archivos de audio?",
|
||||
"MessageConfirmForceReScan": "¿Confirma que quiere forzar un reescaneo?",
|
||||
"MessageConfirmLogoutDevice": "¿Seguro que quieres cerrar la sesión de «{0}»?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "¿Confirma que quiere marcar todos los episodios como terminados?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "¿Confirma que quiere marcar todos los episodios como no terminados?",
|
||||
"MessageConfirmMarkItemFinished": "¿Confirma que quiere marcar «{0}» como terminado?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"ButtonLatest": "Senaste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logga ut",
|
||||
"ButtonLogoutAllDevices": "Logga ut från alla enheter",
|
||||
"ButtonLookup": "Sök",
|
||||
"ButtonManageTracks": "Hantera spår",
|
||||
"ButtonMapChapterTitles": "Mappa kapitelrubriker",
|
||||
@@ -194,6 +195,7 @@
|
||||
"HeaderScheduleEpisodeDownloads": "Schemalägg automatisk hämtning av avsnitt",
|
||||
"HeaderScheduleLibraryScans": "Schemalägg automatiska biblioteksskanningar",
|
||||
"HeaderSession": "Lyssningstillfälle",
|
||||
"HeaderSessions": "Sessioner",
|
||||
"HeaderSetBackupSchedule": "Schemalägg säkerhetskopiering",
|
||||
"HeaderSettings": "Inställningar",
|
||||
"HeaderSettingsDisplay": "Visning",
|
||||
@@ -415,6 +417,7 @@
|
||||
"LabelIntervalEveryHour": "Varje timme",
|
||||
"LabelIntervalEveryMinute": "Varje minut",
|
||||
"LabelInvert": "Invertera",
|
||||
"LabelIpAddress": "IP-adress",
|
||||
"LabelItem": "Objekt",
|
||||
"LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"",
|
||||
"LabelJumpForwardAmount": "Inställning för \"hopp framåt\"",
|
||||
@@ -449,7 +452,7 @@
|
||||
"LabelLowestPriority": "Lägst prioritet",
|
||||
"LabelMatchConfidence": "Matchningsgrad",
|
||||
"LabelMatchExistingUsersBy": "Matcha befintliga användare med",
|
||||
"LabelMatchExistingUsersByDescription": "Används för att ansluta befintlig användare. När anslutningen sker kommer användaren att matchas med ett unikt ID från SSO-leverantören.",
|
||||
"LabelMatchExistingUsersByDescription": "Används för att ansluta befintliga användare. När anslutningen har gjorts matchas användarna med ett unikt ID från SSO-leverantören.",
|
||||
"LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att hämta (0 = obegränsat).",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att hämta per tillfälle",
|
||||
"LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla",
|
||||
@@ -590,7 +593,7 @@
|
||||
"LabelSettingsDateFormat": "Datumformat",
|
||||
"LabelSettingsEnableWatcher": "Bevaka biblioteken automatiskt efter ändringar",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bevaka biblioteket automatiskt efter ändringar",
|
||||
"LabelSettingsEnableWatcherHelp": "Upptäcker automatiskt när objekt<br>läggs till, uppdateras eller tas bort.<br>OBS: Servern måste startas om.",
|
||||
"LabelSettingsEnableWatcherHelp": "Objekt läggs till eller uppdateras automatiskt när filändringar upptäcks.<br>OBS: Servern måste startas om.",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Tillåt skriptat innehåll i EPUB-filer",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt skript i EPUB-filer.<br>Alternativet bör vara inaktiverat<br>om du inte litar på filernas källa.",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentella funktioner",
|
||||
@@ -774,6 +777,7 @@
|
||||
"MessageConfirmDeleteSession": "Är du säker på att du vill ta bort detta lyssningstillfälle?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Är du säker på att du vill infoga metadata i {0} ljudfiler?",
|
||||
"MessageConfirmForceReScan": "Är du säker på att du vill starta en ny skanning?",
|
||||
"MessageConfirmLogoutDevice": "Är du säker på att du vill logga ut från ”{0}”?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Vill du markera alla avsnitt som inte avslutade?",
|
||||
"MessageConfirmMarkItemFinished": "Är du säker på att du vill markera \"{0}\" som avslutad?",
|
||||
@@ -936,7 +940,7 @@
|
||||
"MessageTaskTargetDirectoryNotWritable": "Det går inte att skriva till målmappen",
|
||||
"MessageThinking": "Tänker…",
|
||||
"MessageUploaderItemFailed": "Det gick inte att ladda upp",
|
||||
"MessageUploaderItemSuccess": "har laddats upp",
|
||||
"MessageUploaderItemSuccess": "Uppladdningen lyckades",
|
||||
"MessageUploading": "Laddar upp…",
|
||||
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
||||
"MessageWatcherIsDisabledGlobally": "Bevakningen är inaktiverad i serverinställningarna",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const nodeGlobals = {
|
||||
AbortController: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
structuredClone: 'readonly'
|
||||
}
|
||||
|
||||
const mochaGlobals = {
|
||||
after: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
before: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
describe: 'readonly',
|
||||
it: 'readonly'
|
||||
}
|
||||
|
||||
const rules = {
|
||||
'no-undef': 'error',
|
||||
'no-redeclare': 'error',
|
||||
'no-dupe-keys': 'error',
|
||||
'no-dupe-class-members': 'error',
|
||||
'no-global-assign': 'error'
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/node_modules/**', 'client/**', 'dist/**', 'dist-server/**', 'coverage/**', 'server/libs/**']
|
||||
},
|
||||
{
|
||||
files: ['index.js', 'prod.js', 'dev.js', 'server/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: nodeGlobals
|
||||
},
|
||||
rules
|
||||
},
|
||||
{
|
||||
files: ['test/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...nodeGlobals,
|
||||
...mochaGlobals
|
||||
}
|
||||
},
|
||||
rules
|
||||
}
|
||||
]
|
||||
@@ -16,7 +16,7 @@ const Path = require('path')
|
||||
process.env.NODE_ENV = options.dev ? 'development' : process.env.NODE_ENV || 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
global.appRoot = Path.basename(__dirname) === 'dist-server' ? Path.resolve(__dirname, '..') : __dirname
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
if (isDev || options['prod-with-dev-env']) {
|
||||
@@ -32,9 +32,7 @@ if (isDev || options['prod-with-dev-env']) {
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||
if (devEnv.AllowedDevOrigins) {
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
|
||||
? devEnv.AllowedDevOrigins.join(',')
|
||||
: String(devEnv.AllowedDevOrigins)
|
||||
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins) ? devEnv.AllowedDevOrigins.join(',') : String(devEnv.AllowedDevOrigins)
|
||||
}
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
|
||||
Generated
+929
-22
File diff suppressed because it is too large.
Load diff
+20
-15
@@ -1,35 +1,37 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.36.1",
|
||||
"version": "2.37.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ const { ObjectDefineProperties, ObjectDefineProperty, ObjectSetPrototypeOf } = r
|
||||
|
||||
const stream = require('../../stream')
|
||||
|
||||
const { getDefaultEncoding } = require('../crypto/util')
|
||||
const getDefaultEncoding = () => 'buffer'
|
||||
|
||||
module.exports = LazyTransform
|
||||
|
||||
|
||||
@@ -15,20 +15,23 @@ module.exports = (function () {
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stderr.setEncoding('utf8')
|
||||
|
||||
proc.stdout.on('data', function (data) { probeData.push(data) })
|
||||
proc.stderr.on('data', function (data) { errData.push(data) })
|
||||
proc.stdout.on('data', function (data) {
|
||||
probeData.push(data)
|
||||
})
|
||||
proc.stderr.on('data', function (data) {
|
||||
errData.push(data)
|
||||
})
|
||||
|
||||
proc.on('exit', code => { exitCode = code })
|
||||
proc.on('error', err => reject(err))
|
||||
proc.on('error', (err) => reject(err))
|
||||
proc.on('close', () => {
|
||||
try {
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
resolve(JSON.parse(probeData.join('')))
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return doProbe
|
||||
})()
|
||||
})()
|
||||
@@ -5,8 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WatcherStats = exports.Stats = void 0;
|
||||
const ripstat_1 = require("ripstat");
|
||||
const ripstat_1 = require("./ripstat");
|
||||
Object.defineProperty(exports, "Stats", { enumerable: true, get: function () { return ripstat_1.Stats; } });
|
||||
const watcher_stats_1 = __importDefault(require("./watcher_stats"));
|
||||
exports.WatcherStats = watcher_stats_1.default;
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvdHlwZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUNBLFlBQVk7Ozs7OztBQUdaLHFDQUE4QjtBQWlGbUUsc0ZBakZ6RixlQUFLLE9BaUZ5RjtBQTlFdEcsb0VBQTJDO0FBOEUwSyx1QkE5RTlNLHVCQUFZLENBOEU4TSJ9
|
||||
@@ -272,7 +272,7 @@ class AudioMetadataMangaer {
|
||||
this.tasksRunning = this.tasksRunning.filter((t) => t.id !== task.id)
|
||||
|
||||
if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) {
|
||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`)
|
||||
Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued.length} tasks queued.`)
|
||||
const nextTask = this.tasksQueued.shift()
|
||||
SocketAuthority.emitter('metadata_embed_queue_update', {
|
||||
libraryItemId: nextTask.data.libraryItemId,
|
||||
|
||||
@@ -112,7 +112,7 @@ class BackupManager {
|
||||
.mv(tempPath)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||
Logger.error('[BackupManager] Failed to move backup file', tempPath, error)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
|
||||
@@ -369,8 +369,8 @@ class BinaryManager {
|
||||
* @param {Binary} binary
|
||||
*/
|
||||
async removeBinary(destination, binary) {
|
||||
const binaryPath = path.join(destination, binary.fileName)
|
||||
try {
|
||||
const binaryPath = path.join(destination, binary.fileName)
|
||||
if (await fs.pathExists(binaryPath)) {
|
||||
Logger.debug(`[BinaryManager] Removing binary: ${binaryPath}`)
|
||||
await fs.remove(binaryPath)
|
||||
|
||||
@@ -4,8 +4,13 @@ const stream = require('stream')
|
||||
const Logger = require('../Logger')
|
||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||
const { encodeUriPath } = require('../utils/fileUtils')
|
||||
const { isUUID } = require('../utils/index')
|
||||
const Database = require('../Database')
|
||||
|
||||
function isValidCacheDimension(dimension) {
|
||||
return Number.isSafeInteger(dimension) && dimension > 0
|
||||
}
|
||||
|
||||
class CacheManager {
|
||||
constructor() {
|
||||
this.CachePath = null
|
||||
@@ -40,7 +45,9 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(libraryItemId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
res.type(`image/${format}`)
|
||||
|
||||
const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
@@ -150,7 +157,9 @@ class CacheManager {
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
if (!['webp', 'jpeg', 'png'].includes(format)) return res.sendStatus(400)
|
||||
if (!['webp', 'jpeg', 'png'].includes(format) || !isUUID(authorId) || !isValidCacheDimension(width) || (height !== null && !isValidCacheDimension(height))) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
res.type(`image/${format}`)
|
||||
|
||||
var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
|
||||
|
||||
@@ -138,6 +138,16 @@ class CoverManager {
|
||||
}
|
||||
}
|
||||
coverPath = filePathToPOSIX(coverPath)
|
||||
|
||||
// Must already have been scanned in for the library item
|
||||
const isLibraryItemFile = libraryItem.libraryFiles?.some((lf) => filePathToPOSIX(lf.metadata?.path) === coverPath)
|
||||
if (!isLibraryItemFile) {
|
||||
Logger.error(`[CoverManager] validate cover path is not a library item file "${coverPath}"`)
|
||||
return {
|
||||
error: 'Invalid cover path'
|
||||
}
|
||||
}
|
||||
|
||||
// Cover path already set on media
|
||||
if (libraryItem.media.coverPath == coverPath) {
|
||||
Logger.debug(`[CoverManager] validate cover path already set "${coverPath}"`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 + '/'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) ?? ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ function extractStringOrStringify(json) {
|
||||
return json[Object.keys(json)[0]][0]
|
||||
}
|
||||
// Handles case where html was included without being wrapped in CDATA
|
||||
return JSON.stringify(value)
|
||||
return JSON.stringify(json)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
+14
-14
@@ -12,22 +12,22 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
}
|
||||
|
||||
// Attempt to get bitrate from bps tags
|
||||
var bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
|
||||
const bps = stream.tags.BPS || stream.tags['BPS-eng'] || stream.tags['BPS_eng']
|
||||
if (bps && !isNaN(bps)) {
|
||||
return Number(bps)
|
||||
}
|
||||
|
||||
var tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
|
||||
var tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
|
||||
const tagDuration = stream.tags.DURATION || stream.tags['DURATION-eng'] || stream.tags['DURATION_eng']
|
||||
const tagBytes = stream.tags.NUMBER_OF_BYTES || stream.tags['NUMBER_OF_BYTES-eng'] || stream.tags['NUMBER_OF_BYTES_eng']
|
||||
if (tagDuration && tagBytes && !isNaN(tagDuration) && !isNaN(tagBytes)) {
|
||||
var bps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
|
||||
if (bps && !isNaN(bps)) {
|
||||
return bps
|
||||
const calculatedBps = Math.floor((Number(tagBytes) * 8) / Number(tagDuration))
|
||||
if (calculatedBps && !isNaN(calculatedBps)) {
|
||||
return calculatedBps
|
||||
}
|
||||
}
|
||||
|
||||
if (total_bit_rate && stream.codec_type === 'video') {
|
||||
var estimated_bit_rate = total_bit_rate
|
||||
let estimated_bit_rate = total_bit_rate
|
||||
all_streams.forEach((stream) => {
|
||||
if (stream.bit_rate && !isNaN(stream.bit_rate)) {
|
||||
estimated_bit_rate -= Number(stream.bit_rate)
|
||||
@@ -46,9 +46,9 @@ function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
}
|
||||
|
||||
function tryGrabFrameRate(stream) {
|
||||
var avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
|
||||
let avgFrameRate = stream.avg_frame_rate || stream.r_frame_rate
|
||||
if (!avgFrameRate) return null
|
||||
var parts = avgFrameRate.split('/')
|
||||
const parts = avgFrameRate.split('/')
|
||||
if (parts.length === 2) {
|
||||
avgFrameRate = Number(parts[0]) / Number(parts[1])
|
||||
} else {
|
||||
@@ -59,13 +59,13 @@ function tryGrabFrameRate(stream) {
|
||||
}
|
||||
|
||||
function tryGrabSampleRate(stream) {
|
||||
var sample_rate = stream.sample_rate
|
||||
const sample_rate = stream.sample_rate
|
||||
if (!isNaN(sample_rate)) return Number(sample_rate)
|
||||
return null
|
||||
}
|
||||
|
||||
function tryGrabChannelLayout(stream) {
|
||||
var layout = stream.channel_layout
|
||||
const layout = stream.channel_layout
|
||||
if (!layout) return null
|
||||
return String(layout).split('(').shift()
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function tryGrabTags(stream, ...tags) {
|
||||
}
|
||||
|
||||
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
var info = {
|
||||
const info = {
|
||||
index: stream.index,
|
||||
type: stream.codec_type,
|
||||
codec: stream.codec_name || null,
|
||||
@@ -95,7 +95,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
if (stream.tags) info.tags = stream.tags
|
||||
|
||||
if (info.type === 'audio' || info.type === 'subtitle') {
|
||||
var disposition = stream.disposition || {}
|
||||
const disposition = stream.disposition || {}
|
||||
info.is_default = disposition.default === 1 || disposition.default === '1'
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function parseTags(format, verbose) {
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (!audioStreams || !audioStreams.length) return null
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
var defaultStream = audioStreams.find((a) => a.is_default)
|
||||
const defaultStream = audioStreams.find((a) => a.is_default)
|
||||
if (!defaultStream) return audioStreams[0]
|
||||
return defaultStream
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ const naturalSort = createNewSortInstance({
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
try {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
} catch (error) {
|
||||
Logger.warn(`[libraryFilters] Failed to decode filter value "${text}": ${error.message}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,12 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters')
|
||||
|
||||
module.exports = {
|
||||
decode(text) {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
try {
|
||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
} catch (error) {
|
||||
Logger.warn(`[seriesFilters] Failed to decode filter value "${text}": ${error.message}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+19
-19
@@ -149,16 +149,16 @@ module.exports.buildLibraryFile = buildLibraryFile
|
||||
function getBookDataFromDir(relPath, parseSubtitle = false) {
|
||||
const splitDir = relPath.split('/')
|
||||
|
||||
var folder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
const titleFolder = splitDir.pop() // Audio files will always be in the directory named for the title
|
||||
const series = splitDir.length > 1 ? splitDir.pop() : null // If there are at least 2 more directories, next furthest will be the series
|
||||
const author = splitDir.length > 0 ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
// The may contain various other pieces of metadata, these functions extract it.
|
||||
var [folder, asin] = getASIN(folder)
|
||||
var [folder, narrators] = getNarrator(folder)
|
||||
var [folder, sequence] = series ? getSequence(folder) : [folder, null]
|
||||
var [folder, publishedYear] = getPublishedYear(folder)
|
||||
var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null]
|
||||
// Each extractor strips one piece of metadata and returns the remaining folder name.
|
||||
const [afterAsin, asin] = getASIN(titleFolder)
|
||||
const [afterNarrators, narrators] = getNarrator(afterAsin)
|
||||
const [afterSequence, sequence] = series ? getSequence(afterNarrators) : [afterNarrators, null]
|
||||
const [afterYear, publishedYear] = getPublishedYear(afterSequence)
|
||||
const [title, subtitle] = parseSubtitle ? getSubtitle(afterYear) : [afterYear, null]
|
||||
|
||||
return {
|
||||
title,
|
||||
@@ -180,8 +180,8 @@ module.exports.getBookDataFromDir = getBookDataFromDir
|
||||
* @returns {[string, string]} [folder, narrator]
|
||||
*/
|
||||
function getNarrator(folder) {
|
||||
let pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
let match = folder.match(pattern)
|
||||
const pattern = /^(?<title>.*) \{(?<narrators>.*)\}$/
|
||||
const match = folder.match(pattern)
|
||||
return match ? [match.groups.title, match.groups.narrators] : [folder, null]
|
||||
}
|
||||
|
||||
@@ -204,12 +204,12 @@ function getNarrator(folder) {
|
||||
*/
|
||||
function getSequence(folder) {
|
||||
// Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later.
|
||||
let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
const pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i
|
||||
|
||||
let volumeNumber = null
|
||||
let parts = folder.split(' - ')
|
||||
const parts = folder.split(' - ')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let match = parts[i].match(pattern)
|
||||
const match = parts[i].match(pattern)
|
||||
// This excludes '101 Dalmations' but includes '101. Dalmations'
|
||||
if (match && !(match.groups.suffix && !(match.groups.volumeLabel || match.groups.trailingDot))) {
|
||||
volumeNumber = isNaN(match.groups.sequence) ? match.groups.sequence : Number(match.groups.sequence).toString()
|
||||
@@ -232,10 +232,10 @@ function getSequence(folder) {
|
||||
* @returns {[string, string]} [folder, publishedYear]
|
||||
*/
|
||||
function getPublishedYear(folder) {
|
||||
var publishedYear = null
|
||||
let publishedYear = null
|
||||
|
||||
pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||
var match = folder.match(pattern)
|
||||
const pattern = /^ *\(?([0-9]{4})\)? * - *(.+)/ //Matches #### - title or (####) - title
|
||||
const match = folder.match(pattern)
|
||||
if (match) {
|
||||
publishedYear = match[1]
|
||||
folder = match[2]
|
||||
@@ -252,7 +252,7 @@ function getPublishedYear(folder) {
|
||||
*/
|
||||
function getSubtitle(folder) {
|
||||
// Subtitle is everything after " - "
|
||||
var splitTitle = folder.split(' - ')
|
||||
const splitTitle = folder.split(' - ')
|
||||
return [splitTitle.shift(), splitTitle.join(' - ')]
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ function getSubtitle(folder) {
|
||||
function getASIN(folder) {
|
||||
let asin = null
|
||||
|
||||
let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
|
||||
const pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]"
|
||||
const match = folder.match(pattern)
|
||||
if (match) {
|
||||
asin = match[1]
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -45,4 +45,26 @@ describe('Audible', () => {
|
||||
expect(result).to.equal('.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanResult', () => {
|
||||
const baseItem = {
|
||||
title: 'Some Book',
|
||||
formatType: 'unabridged'
|
||||
}
|
||||
|
||||
it('should set explicit to true when isAdult is true', () => {
|
||||
const result = audible.cleanResult({ ...baseItem, isAdult: true })
|
||||
expect(result.explicit).to.equal(true)
|
||||
})
|
||||
|
||||
it('should set explicit to false when isAdult is false', () => {
|
||||
const result = audible.cleanResult({ ...baseItem, isAdult: false })
|
||||
expect(result.explicit).to.equal(false)
|
||||
})
|
||||
|
||||
it('should set explicit to false when isAdult is not present', () => {
|
||||
const result = audible.cleanResult({ ...baseItem })
|
||||
expect(result.explicit).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-server",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmitOnError": true,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "dist-server/.tsbuildinfo",
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user