From ba4061e5a406c03bffe178232a0a5f7172649ceb Mon Sep 17 00:00:00 2001 From: Hadrien Patte Date: Mon, 16 Jun 2025 23:03:02 +0200 Subject: [PATCH 01/11] Make `NUSQLITE3_PATH` build arg configurable --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f9c46117..816bdd3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,7 @@ WORKDIR /app # Copy compiled frontend and server from build stages COPY --from=build-client /client/dist /app/client/dist COPY --from=build-server /server /app -COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3 +COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH} EXPOSE 80 From 1f7be58124dce737ac252d08a7eefaa6990a6077 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 16 Jun 2025 17:50:53 -0500 Subject: [PATCH 02/11] Fix database cleanup query pulling duplicate mediaProgresses --- server/Database.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/server/Database.js b/server/Database.js index eb384f25..a260e89f 100644 --- a/server/Database.js +++ b/server/Database.js @@ -766,14 +766,25 @@ class Database { Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) } - // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt) - // const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, userId, updatedAt) IN (SELECT mediaItemId, userId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId, userId HAVING COUNT(*) > 1)`) - // for (const duplicateMediaProgress of duplicateMediaProgresses) { - // Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`) - // await this.mediaProgressModel.destroy({ - // where: { id: duplicateMediaProgress.id } - // }) - // } + // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one) + const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId +FROM mediaProgresses mp1 +WHERE EXISTS ( + SELECT 1 + FROM mediaProgresses mp2 + WHERE mp2.mediaItemId = mp1.mediaItemId + AND mp2.userId = mp1.userId + AND ( + mp2.updatedAt > mp1.updatedAt + OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id) + ) +)`) + for (const duplicateMediaProgress of duplicateMediaProgresses) { + Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`) + await this.mediaProgressModel.destroy({ + where: { id: duplicateMediaProgress.id } + }) + } } async createTextSearchQuery(query) { From 9d4303ef7b48f6b1854d09c8ee4bc01c2e0867b8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 17 Jun 2025 16:25:30 -0500 Subject: [PATCH 03/11] Update book library secondary title sort to use title ignore prefixes #4414 --- .../utils/queries/libraryItemsBookFilters.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 73d36c58..7fa5eb41 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -251,6 +251,15 @@ module.exports = { */ getOrder(sortBy, sortDesc, collapseseries) { const dir = sortDesc ? 'DESC' : 'ASC' + + const getTitleOrder = () => { + if (global.ServerSettings.sortingIgnorePrefix) { + return [Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir] + } else { + return [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] + } + } + if (sortBy === 'addedAt') { return [[Sequelize.literal('libraryItem.createdAt'), dir]] } else if (sortBy === 'size') { @@ -264,25 +273,16 @@ module.exports = { } else if (sortBy === 'media.metadata.publishedYear') { return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { - return [ - [Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], - [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] - ] + // Sort by author name last first, secondary sort by title + return [[Sequelize.literal('`libraryItem`.`authorNamesLastFirst` COLLATE NOCASE'), dir], getTitleOrder()] } else if (sortBy === 'media.metadata.authorName') { - return [ - [Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], - [Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir] - ] + // Sort by author name first last, secondary sort by title + return [[Sequelize.literal('`libraryItem`.`authorNamesFirstLast` COLLATE NOCASE'), dir], getTitleOrder()] } else if (sortBy === 'media.metadata.title') { if (collapseseries) { return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] } - - if (global.ServerSettings.sortingIgnorePrefix) { - return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] - } else { - return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]] - } + return [getTitleOrder()] } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS FLOAT) ${nullDir}`)]] From 9bf8d7de11b969bb7ece861d0321db33d1b016c9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 17 Jun 2025 17:21:21 -0500 Subject: [PATCH 04/11] Fix server crash when FantLab provider request times out #4410 --- server/providers/FantLab.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/providers/FantLab.js b/server/providers/FantLab.js index dd9f60cc..f33934ca 100644 --- a/server/providers/FantLab.js +++ b/server/providers/FantLab.js @@ -52,9 +52,7 @@ class FantLab { return [] }) - return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => { - return resArray.filter((res) => res) - }) + return Promise.all(items.map(async (item) => await this.getWork(item, timeout))).then((resArray) => resArray.filter(Boolean)) } /** @@ -83,6 +81,10 @@ class FantLab { return null }) + if (!bookData) { + return null + } + return this.cleanBookData(bookData, timeout) } From b8849677de53859aa7f49d944984d7de06890e73 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 18 Jun 2025 17:20:36 -0500 Subject: [PATCH 05/11] Episode view modal makes timestamps in description clickable --- .../components/modals/podcast/ViewEpisode.vue | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/client/components/modals/podcast/ViewEpisode.vue b/client/components/modals/podcast/ViewEpisode.vue index 5a520ef4..b4358c5d 100644 --- a/client/components/modals/podcast/ViewEpisode.vue +++ b/client/components/modals/podcast/ViewEpisode.vue @@ -16,7 +16,7 @@

{{ title }}

-
+

{{ $strings.MessageNoDescription }}

@@ -68,7 +68,7 @@ export default { return this.episode.title || 'No Episode Title' }, description() { - return this.episode.description || '' + return this.parseDescription(this.episode.description || '') }, media() { return this.libraryItem?.media || {} @@ -94,7 +94,41 @@ export default { return this.$store.getters['libraries/getBookCoverAspectRatio'] } }, - methods: {}, + methods: { + handleDescriptionClick(e) { + if (e.target.matches('span.time-marker')) { + const time = parseInt(e.target.dataset.time) + if (!isNaN(time)) { + this.$eventBus.$emit('play-item', { + episodeId: this.episodeId, + libraryItemId: this.libraryItem.id, + startTime: time + }) + } + e.preventDefault() + } + }, + parseDescription(description) { + const timeMarkerLinkRegex = /(.*?)<\/a>/g + const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g + + function convertToSeconds(time) { + const timeParts = time.split(':').map(Number) + return timeParts.reduce((acc, part, index) => acc * 60 + part, 0) + } + + return description + .replace(timeMarkerLinkRegex, (match, href, displayTime) => { + const time = displayTime.match(timeMarkerRegex)[0] + const seekTimeInSeconds = convertToSeconds(time) + return `${displayTime}` + }) + .replace(timeMarkerRegex, (match) => { + const seekTimeInSeconds = convertToSeconds(match) + return `${match}` + }) + } + }, mounted() {} } From c150ed4e989a668501edfffad6b0754c501d35df Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 19 Jun 2025 17:14:56 -0500 Subject: [PATCH 06/11] Update view episode modal to include duration & episode feed modal to include duration & size --- client/components/modals/podcast/EpisodeFeed.vue | 9 ++++++++- client/components/modals/podcast/ViewEpisode.vue | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 7ec14ccd..e62342af 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -35,7 +35,14 @@

{{ episode.subtitle }}

-

Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}

+
+ +

Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}

+ +

{{ $strings.LabelDuration }}: {{ $elapsedPretty(Number(episode.duration)) }}

+ +

{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}

+
diff --git a/client/components/modals/podcast/ViewEpisode.vue b/client/components/modals/podcast/ViewEpisode.vue index b4358c5d..2502a5ea 100644 --- a/client/components/modals/podcast/ViewEpisode.vue +++ b/client/components/modals/podcast/ViewEpisode.vue @@ -34,6 +34,12 @@ {{ audioFileSize }}

+
+

{{ $strings.LabelDuration }}

+

+ {{ audioFileDuration }} +

+
@@ -90,6 +96,10 @@ export default { return this.$bytesPretty(size) }, + audioFileDuration() { + const duration = this.episode.duration || 0 + return this.$elapsedPretty(duration) + }, bookCoverAspectRatio() { return this.$store.getters['libraries/getBookCoverAspectRatio'] } From 7b92c15a465fa6eae62145250d5c6709a2dda1b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 19 Jun 2025 17:28:21 -0500 Subject: [PATCH 07/11] Include durationSeconds on RSS podcast episode parsed from duration --- client/components/modals/podcast/EpisodeFeed.vue | 2 +- server/utils/podcastUtils.js | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index e62342af..6b99cee7 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -39,7 +39,7 @@

Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}

-

{{ $strings.LabelDuration }}: {{ $elapsedPretty(Number(episode.duration)) }}

+

{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}

{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}

diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 12469160..b62e024f 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -25,6 +25,7 @@ const Fuse = require('../libs/fusejs') * @property {string} episode * @property {string} author * @property {string} duration + * @property {number|null} durationSeconds - Parsed from duration string if duration is valid * @property {string} explicit * @property {number} publishedAt - Unix timestamp * @property {{ url: string, type?: string, length?: string }} enclosure @@ -217,8 +218,9 @@ function extractEpisodeData(item) { }) // Extract psc:chapters if duration is set - let episodeDuration = !isNaN(episode.duration) ? timestampToSeconds(episode.duration) : null - if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episodeDuration) { + episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null + + if (item['psc:chapters']?.[0]?.['psc:chapter']?.length && episode.durationSeconds) { // Example chapter: // {"id":0,"start":0,"end":43.004286,"title":"chapter 1"} @@ -244,7 +246,7 @@ function extractEpisodeData(item) { } else { episode.chapters = cleanedChapters.map((chapter, index) => { const nextChapter = cleanedChapters[index + 1] - const end = nextChapter ? nextChapter.start : episodeDuration + const end = nextChapter ? nextChapter.start : episode.durationSeconds return { id: chapter.id, title: chapter.title, @@ -273,6 +275,7 @@ function cleanEpisodeData(data) { episode: data.episode || '', author: data.author || '', duration: data.duration || '', + durationSeconds: data.durationSeconds || null, explicit: data.explicit || '', publishedAt, enclosure: data.enclosure, From bb4eec935515f5fe945b970ec2cef2b3355a4e2d Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 21 Jun 2025 12:02:44 +0200 Subject: [PATCH 08/11] add explicit --- client/components/controls/LibraryFilterSelect.vue | 5 +++++ server/utils/queries/libraryItemsBookFilters.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index f5eec41a..7bfa1fe8 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -228,6 +228,11 @@ export default { value: 'abridged', sublist: false }, + { + text: this.$strings.LabelExplicit, + value: 'explicit', + sublist: false + }, { text: this.$strings.ButtonIssues, value: 'issues', diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7fa5eb41..ded712cf 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -186,6 +186,8 @@ module.exports = { mediaWhere['$series.id$'] = null } else if (group === 'abridged') { mediaWhere['abridged'] = true + } else if (group === 'explicit') { + mediaWhere['explicit'] = true } else if (['genres', 'tags', 'narrators'].includes(group)) { mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), { [Sequelize.Op.gte]: 1 From 5336d0525e7bd46150369f50e08d06e1145a55b3 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 21 Jun 2025 12:29:54 +0200 Subject: [PATCH 09/11] add explicit to podcasts --- client/components/controls/LibraryFilterSelect.vue | 5 +++++ server/utils/queries/libraryItemsPodcastFilters.js | 2 ++ 2 files changed, 7 insertions(+) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 7bfa1fe8..32bb9a86 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -277,6 +277,11 @@ export default { value: 'languages', sublist: true }, + { + text: this.$strings.LabelExplicit, + value: 'explicit', + sublist: false + }, { text: this.$strings.ButtonIssues, value: 'issues', diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 33bac28f..8bb5dc11 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -59,6 +59,8 @@ module.exports = { replacements.filterValue = value } else if (group === 'languages') { mediaWhere['language'] = value + } else if (group === 'explicit') { + mediaWhere['explicit'] = true } return { From af684e6a69bf4c0d58f2f3c019e9788d6e20876c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 21 Jun 2025 17:01:13 -0500 Subject: [PATCH 10/11] Explicit library filter not shown for users without permission --- .../controls/LibraryFilterSelect.vue | 34 +++++++++++++------ client/store/user.js | 3 ++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 32bb9a86..62a9b803 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -94,6 +94,9 @@ export default { userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] }, + userCanAccessExplicitContent() { + return this.$store.getters['user/getUserCanAccessExplicitContent'] + }, libraryMediaType() { return this.$store.getters['libraries/getCurrentLibraryMediaType'] }, @@ -228,11 +231,6 @@ export default { value: 'abridged', sublist: false }, - { - text: this.$strings.LabelExplicit, - value: 'explicit', - sublist: false - }, { text: this.$strings.ButtonIssues, value: 'issues', @@ -244,6 +242,15 @@ export default { sublist: false } ] + + if (this.userCanAccessExplicitContent) { + items.push({ + text: this.$strings.LabelExplicit, + value: 'explicit', + sublist: false + }) + } + if (this.userIsAdminOrUp) { items.push({ text: this.$strings.LabelShareOpen, @@ -254,7 +261,7 @@ export default { return items }, podcastItems() { - return [ + const items = [ { text: this.$strings.LabelAll, value: 'all' @@ -277,11 +284,6 @@ export default { value: 'languages', sublist: true }, - { - text: this.$strings.LabelExplicit, - value: 'explicit', - sublist: false - }, { text: this.$strings.ButtonIssues, value: 'issues', @@ -293,6 +295,16 @@ export default { sublist: false } ] + + if (this.userCanAccessExplicitContent) { + items.push({ + text: this.$strings.LabelExplicit, + value: 'explicit', + sublist: false + }) + } + + return items }, selectItems() { if (this.isSeries) return this.seriesItems diff --git a/client/store/user.js b/client/store/user.js index d5aad19b..41e12cad 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -58,6 +58,9 @@ export const getters = { getUserCanAccessAllLibraries: (state) => { return !!state.user?.permissions?.accessAllLibraries }, + getUserCanAccessExplicitContent: (state) => { + return !!state.user?.permissions?.accessExplicitContent + }, getLibrariesAccessible: (state, getters) => { if (!state.user) return [] if (getters.getUserCanAccessAllLibraries) return [] From a992400d6abd9ca96e9b637e987ec46e4fbc5a08 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 23 Jun 2025 16:56:08 -0500 Subject: [PATCH 11/11] Add ENV REACT_CLIENT_PATH to target a Nextjs frontend instead of Nuxt --- index.js | 1 + server/Auth.js | 13 ++++++++- server/Server.js | 74 ++++++++++++++++++++++++++++-------------------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/index.js b/index.js index 2839c238..7fcd90fc 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,7 @@ if (isDev) { if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath + if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' } diff --git a/server/Auth.js b/server/Auth.js index ba72947a..4e76ee33 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -442,7 +442,17 @@ class Auth { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { // return the user login response json if the login was successfull - res.json(await this.getUserLoginResponsePayload(req.user)) + const userResponse = await this.getUserLoginResponsePayload(req.user) + + // Experimental Next.js client uses bearer token in cookies + res.cookie('auth_token', userResponse.user.token, { + httpOnly: true, + secure: req.secure || req.get('x-forwarded-proto') === 'https', + sameSite: 'strict', + maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days + }) + + res.json(userResponse) }) // openid strategy login route (this redirects to the configured openid login provider) @@ -718,6 +728,7 @@ class Auth { const authMethod = req.cookies.auth_method res.clearCookie('auth_method') + res.clearCookie('auth_token') let logoutUrl = null diff --git a/server/Server.js b/server/Server.js index 5c6f3c16..22a53a3a 100644 --- a/server/Server.js +++ b/server/Server.js @@ -220,6 +220,7 @@ class Server { async start() { Logger.info('=== Starting Server ===') + this.initProcessEventListeners() await this.init() @@ -281,6 +282,7 @@ class Server { await this.auth.initPassportJs() const router = express.Router() + // if RouterBasePath is set, modify all requests to include the base path app.use((req, res, next) => { const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) @@ -313,10 +315,6 @@ class Server { router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) - // Static path to generated nuxt - const distPath = Path.join(global.appRoot, '/client/dist') - router.use(express.static(distPath)) - // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) @@ -336,32 +334,6 @@ class Server { // Auth routes await this.auth.initAuthRoutes(router) - // Client dynamic routes - const dynamicRoutes = [ - '/item/:id', - '/author/:id', - '/audiobook/:id/chapters', - '/audiobook/:id/edit', - '/audiobook/:id/manage', - '/library/:library', - '/library/:library/search', - '/library/:library/bookshelf/:id?', - '/library/:library/authors', - '/library/:library/narrators', - '/library/:library/stats', - '/library/:library/series/:id?', - '/library/:library/podcast/search', - '/library/:library/podcast/latest', - '/library/:library/podcast/download-queue', - '/config/users/:id', - '/config/users/:id/sessions', - '/config/item-metadata-utils/:id', - '/collection/:id', - '/playlist/:id', - '/share/:slug' - ] - dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) - router.post('/init', (req, res) => { if (Database.hasRootUser) { Logger.error(`[Server] attempt to init server when server already has a root user`) @@ -392,6 +364,48 @@ class Server { }) router.get('/healthcheck', (req, res) => res.sendStatus(200)) + const ReactClientPath = process.env.REACT_CLIENT_PATH + if (!ReactClientPath) { + // Static path to generated nuxt + const distPath = Path.join(global.appRoot, '/client/dist') + router.use(express.static(distPath)) + + // Client dynamic routes + const dynamicRoutes = [ + '/item/:id', + '/author/:id', + '/audiobook/:id/chapters', + '/audiobook/:id/edit', + '/audiobook/:id/manage', + '/library/:library', + '/library/:library/search', + '/library/:library/bookshelf/:id?', + '/library/:library/authors', + '/library/:library/narrators', + '/library/:library/stats', + '/library/:library/series/:id?', + '/library/:library/podcast/search', + '/library/:library/podcast/latest', + '/library/:library/podcast/download-queue', + '/config/users/:id', + '/config/users/:id/sessions', + '/config/item-metadata-utils/:id', + '/collection/:id', + '/playlist/:id', + '/share/:slug' + ] + dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) + } else { + // This is for using the experimental Next.js client + Logger.info(`Using React client at ${ReactClientPath}`) + const nextPath = Path.join(ReactClientPath, 'node_modules/next') + const next = require(nextPath) + const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath }) + const handle = nextApp.getRequestHandler() + await nextApp.prepare() + router.get('*', (req, res) => handle(req, res)) + } + const unixSocketPrefix = 'unix/' if (this.Host?.startsWith(unixSocketPrefix)) { const sockPath = this.Host.slice(unixSocketPrefix.length)