@@ -335,8 +335,11 @@ export default {
}
},
resize() {
- this.windowWidth = window.innerWidth
- this.windowHeight = window.innerHeight
+ setTimeout(() => {
+ this.windowWidth = window.innerWidth
+ this.windowHeight = window.innerHeight
+ this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
+ }, 100)
},
playerError(error) {
console.error('Player error', error)
diff --git a/client/players/AudioTrack.js b/client/players/AudioTrack.js
index 05f11ad9..d2f00eea 100644
--- a/client/players/AudioTrack.js
+++ b/client/players/AudioTrack.js
@@ -1,5 +1,5 @@
export default class AudioTrack {
- constructor(track, userToken, routerBasePath) {
+ constructor(track, sessionId, routerBasePath) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
@@ -8,28 +8,29 @@ export default class AudioTrack {
this.mimeType = track.mimeType
this.metadata = track.metadata || {}
- this.userToken = userToken
+ this.sessionId = sessionId
this.routerBasePath = routerBasePath || ''
+ if (this.contentUrl?.startsWith('/hls')) {
+ this.sessionTrackUrl = this.contentUrl
+ } else {
+ this.sessionTrackUrl = `/public/session/${sessionId}/track/${this.index}`
+ }
}
/**
* Used for CastPlayer
*/
get fullContentUrl() {
- if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
-
if (process.env.NODE_ENV === 'development') {
- return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}`
+ return `${process.env.serverUrl}${this.sessionTrackUrl}`
}
- return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
+ return `${window.location.origin}${this.routerBasePath}${this.sessionTrackUrl}`
}
/**
* Used for LocalPlayer
*/
get relativeContentUrl() {
- if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
-
- return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}`
+ return `${this.routerBasePath}${this.sessionTrackUrl}`
}
}
diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js
index 6e4baa45..5c5f281f 100644
--- a/client/players/PlayerHandler.js
+++ b/client/players/PlayerHandler.js
@@ -37,9 +37,6 @@ export default class PlayerHandler {
get isPlayingLocalItem() {
return this.libraryItem && this.player instanceof LocalAudioPlayer
}
- get userToken() {
- return this.ctx.$store.getters['user/getToken']
- }
get playerPlaying() {
return this.playerState === 'PLAYING'
}
@@ -226,7 +223,7 @@ export default class PlayerHandler {
console.log('[PlayerHandler] Preparing Session', session)
- var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken, this.ctx.$config.routerBasePath))
+ var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, session.id, this.ctx.$config.routerBasePath))
this.ctx.playerLoading = true
this.isHlsTranscode = true
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index 101ee161..8ef7f24c 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -177,6 +177,7 @@
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastsToAdd": "Podcasts to Add",
+ "HeaderPresets": "Presets",
"HeaderPreviewCover": "Preview Cover",
"HeaderRSSFeedGeneral": "RSS Details",
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
diff --git a/server/Server.js b/server/Server.js
index c3e73aec..0e53d3e9 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -313,7 +313,7 @@ class Server {
router.use(express.json({ limit: '5mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
- router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
+ router.use('/hls', this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// Static path to generated nuxt
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 768a23dc..5247dbb0 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -834,8 +834,8 @@ class LibraryItemController {
}
if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
- Logger.error(`[LibraryItemController] Invalid library item`)
- return res.sendStatus(500)
+ Logger.error(`[LibraryItemController] getMetadataObject: Invalid library item "${req.libraryItem.media.title}"`)
+ return res.sendStatus(400)
}
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem))
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index 06cd4d84..0e5ad141 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -222,7 +222,7 @@ class MiscController {
// Update nameIgnorePrefix column on series
const allSeries = await Database.seriesModel.findAll({
- attributes: ['id', 'name', 'nameIgnorePrefix']
+ attributes: ['id', 'name', 'nameIgnorePrefix', 'libraryId']
})
const bulkUpdateSeries = []
allSeries.forEach((series) => {
@@ -230,6 +230,8 @@ class MiscController {
if (nameIgnorePrefix !== series.nameIgnorePrefix) {
bulkUpdateSeries.push({
id: series.id,
+ name: series.name,
+ libraryId: series.libraryId,
nameIgnorePrefix
})
}
diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js
index c3361ce9..8cebdd35 100644
--- a/server/controllers/SessionController.js
+++ b/server/controllers/SessionController.js
@@ -1,7 +1,9 @@
+const Path = require('path')
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const { toNumber, isUUID } = require('../utils/index')
+const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
const ShareManager = require('../managers/ShareManager')
@@ -266,6 +268,51 @@ class SessionController {
this.playbackSessionManager.syncLocalSessionsRequest(req, res)
}
+ /**
+ * GET: /public/session/:id/track/:index
+ * While a session is open, this endpoint can be used to stream the audio track
+ *
+ * @this {import('../routers/PublicRouter')}
+ *
+ * @param {Request} req
+ * @param {Response} res
+ */
+ async getTrack(req, res) {
+ const audioTrackIndex = toNumber(req.params.index, null)
+ if (audioTrackIndex === null) {
+ Logger.error(`[SessionController] Invalid audio track index "${req.params.index}"`)
+ return res.sendStatus(400)
+ }
+
+ const playbackSession = this.playbackSessionManager.getSession(req.params.id)
+ if (!playbackSession) {
+ Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
+ return res.sendStatus(404)
+ }
+
+ const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex)
+ if (!audioTrack) {
+ Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)
+ return res.sendStatus(404)
+ }
+
+ const user = await Database.userModel.getUserById(playbackSession.userId)
+ Logger.debug(`[SessionController] Serving audio track ${audioTrack.index} for session "${req.params.id}" belonging to user "${user.username}"`)
+
+ if (global.XAccel) {
+ const encodedURI = encodeUriPath(global.XAccel + audioTrack.metadata.path)
+ Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
+ return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
+ }
+
+ // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
+ const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(audioTrack.metadata.path))
+ if (audioMimeType) {
+ res.setHeader('Content-Type', audioMimeType)
+ }
+ res.sendFile(audioTrack.metadata.path)
+ }
+
/**
*
* @param {RequestWithUser} req
diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js
index 94122b46..9f2014ec 100644
--- a/server/controllers/ToolsController.js
+++ b/server/controllers/ToolsController.js
@@ -48,6 +48,7 @@ class ToolsController {
}
const options = req.query || {}
+ Logger.info(`[ToolsController] encodeM4b: Starting audiobook merge for "${req.libraryItem.media.title}" with options: ${JSON.stringify(options)}`)
this.abMergeManager.startAudiobookMerge(req.user.id, req.libraryItem, options)
res.sendStatus(200)
diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js
index f410cdaf..39bd2e8f 100644
--- a/server/managers/PlaybackSessionManager.js
+++ b/server/managers/PlaybackSessionManager.js
@@ -26,6 +26,12 @@ class PlaybackSessionManager {
this.sessions = []
}
+ /**
+ * Get open session by id
+ *
+ * @param {string} sessionId
+ * @returns {PlaybackSession}
+ */
getSession(sessionId) {
return this.sessions.find((s) => s.id === sessionId)
}
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index bf561d5e..16a52161 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -246,7 +246,6 @@ class LibraryItem extends Model {
include
})
if (!libraryItem) {
- Logger.error(`[LibraryItem] Library item not found`)
return null
}
diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js
index 107edf99..092414be 100644
--- a/server/routers/PublicRouter.js
+++ b/server/routers/PublicRouter.js
@@ -1,5 +1,6 @@
const express = require('express')
const ShareController = require('../controllers/ShareController')
+const SessionController = require('../controllers/SessionController')
class PublicRouter {
constructor(playbackSessionManager) {
@@ -17,6 +18,7 @@ class PublicRouter {
this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this))
this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this))
this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this))
+ this.router.get('/session/:id/track/:index', SessionController.getTrack.bind(this))
}
}
module.exports = PublicRouter
diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js
index 4d0285dd..bc174d7a 100644
--- a/server/scanner/LibraryScanner.js
+++ b/server/scanner/LibraryScanner.js
@@ -407,7 +407,7 @@ class LibraryScanner {
const folder = library.libraryFolders[0]
const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate))
- const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly)
+ const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly, true)
if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`)
diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js
index 2c935557..f80c4acd 100644
--- a/server/utils/fileUtils.js
+++ b/server/utils/fileUtils.js
@@ -242,7 +242,7 @@ module.exports.recurseFiles = async (path, relPathToReplace = null) => {
})
.filter((item) => {
// Filter out items in ignore directories
- if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir))) {
+ if (directoriesToIgnore.some((dir) => item.fullname.startsWith(dir + '/'))) {
Logger.debug(`[fileUtils] Ignoring path in dir with .ignore "${item.fullname}"`)
return false
}
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 581d4689..99c3cbd2 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -1247,7 +1247,12 @@ module.exports = {
libraryId
}
})
- return statResults[0]
+ return {
+ totalSize: statResults?.[0]?.totalSize || 0,
+ totalDuration: statResults?.[0]?.totalDuration || 0,
+ numAudioFiles: statResults?.[0]?.numAudioFiles || 0,
+ totalItems: statResults?.[0]?.totalItems || 0
+ }
},
/**
diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js
index 26f7ba1b..c71e0dc8 100644
--- a/server/utils/queries/libraryItemsPodcastFilters.js
+++ b/server/utils/queries/libraryItemsPodcastFilters.js
@@ -533,8 +533,10 @@ module.exports = {
}
})
return {
- ...statResults[0],
- totalSize: sizeResults[0].totalSize || 0
+ totalDuration: statResults?.[0]?.totalDuration || 0,
+ numAudioFiles: statResults?.[0]?.numAudioFiles || 0,
+ totalItems: statResults?.[0]?.totalItems || 0,
+ totalSize: sizeResults?.[0]?.totalSize || 0
}
},
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index 0aaa5e19..6dd2d67f 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -24,6 +24,12 @@ function isMediaFile(mediaType, ext, audiobooksOnly = false) {
return globals.SupportedAudioTypes.includes(extclean) || globals.SupportedEbookTypes.includes(extclean)
}
+function isScannableNonMediaFile(ext) {
+ if (!ext) return false
+ const extclean = ext.slice(1).toLowerCase()
+ return globals.TextFileTypes.includes(extclean) || globals.MetadataFileTypes.includes(extclean) || globals.SupportedImageTypes.includes(extclean)
+}
+
function checkFilepathIsAudioFile(filepath) {
const ext = Path.extname(filepath)
if (!ext) return false
@@ -35,27 +41,31 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile
/**
* @param {string} mediaType
* @param {import('./fileUtils').FilePathItem[]} fileItems
- * @param {boolean} [audiobooksOnly=false]
+ * @param {boolean} audiobooksOnly
+ * @param {boolean} [includeNonMediaFiles=false] - Used by the watcher to re-scan when covers/metadata files are added/removed
* @returns {Record} map of files grouped into potential libarary item dirs
*/
-function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly = false) {
+function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly, includeNonMediaFiles = false) {
// Step 1: Filter out non-book-media files in root dir (with depth of 0)
const itemsFiltered = fileItems.filter((i) => {
return i.deep > 0 || (mediaType === 'book' && isMediaFile(mediaType, i.extension, audiobooksOnly))
})
// Step 2: Separate media files and other files
- // - Directories without a media file will not be included
+ // - Directories without a media file will not be included (unless includeNonMediaFiles is true)
/** @type {import('./fileUtils').FilePathItem[]} */
const mediaFileItems = []
/** @type {import('./fileUtils').FilePathItem[]} */
const otherFileItems = []
itemsFiltered.forEach((item) => {
- if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item)
- else otherFileItems.push(item)
+ if (isMediaFile(mediaType, item.extension, audiobooksOnly) || (includeNonMediaFiles && isScannableNonMediaFile(item.extension))) {
+ mediaFileItems.push(item)
+ } else {
+ otherFileItems.push(item)
+ }
})
- // Step 3: Group audio files in library items
+ // Step 3: Group media files (or non-media files if includeNonMediaFiles is true) in library items
const libraryItemGroup = {}
mediaFileItems.forEach((item) => {
const dirparts = item.reldirpath.split('/').filter((p) => !!p)
diff --git a/test/server/utils/fileUtils.test.js b/test/server/utils/fileUtils.test.js
index 59907be9..b57a6fb8 100644
--- a/test/server/utils/fileUtils.test.js
+++ b/test/server/utils/fileUtils.test.js
@@ -47,7 +47,7 @@ describe('fileUtils', () => {
// Mock file structure with normalized paths
const mockDirContents = new Map([
- ['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'temp.mp3.tmp']],
+ ['/test', ['file1.mp3', 'subfolder', 'ignoreme', 'ignoremenot.mp3', 'temp.mp3.tmp']],
['/test/subfolder', ['file2.m4b']],
['/test/ignoreme', ['.ignore', 'ignored.mp3']]
])
@@ -59,7 +59,8 @@ describe('fileUtils', () => {
['/test/ignoreme', { isDirectory: () => true, size: 0, mtimeMs: Date.now(), ino: '4' }],
['/test/ignoreme/.ignore', { isDirectory: () => false, size: 0, mtimeMs: Date.now(), ino: '5' }],
['/test/ignoreme/ignored.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '6' }],
- ['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }]
+ ['/test/ignoremenot.mp3', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '7' }],
+ ['/test/temp.mp3.tmp', { isDirectory: () => false, size: 1024, mtimeMs: Date.now(), ino: '8' }]
])
// Stub fs.readdir
@@ -103,7 +104,7 @@ describe('fileUtils', () => {
it('should return filtered file list', async () => {
const files = await fileUtils.recurseFiles('/test')
expect(files).to.be.an('array')
- expect(files).to.have.lengthOf(2)
+ expect(files).to.have.lengthOf(3)
expect(files[0]).to.deep.equal({
name: 'file1.mp3',
@@ -115,6 +116,15 @@ describe('fileUtils', () => {
})
expect(files[1]).to.deep.equal({
+ name: 'ignoremenot.mp3',
+ path: 'ignoremenot.mp3',
+ reldirpath: '',
+ fullpath: '/test/ignoremenot.mp3',
+ extension: '.mp3',
+ deep: 0
+ })
+
+ expect(files[2]).to.deep.equal({
name: 'file2.m4b',
path: 'subfolder/file2.m4b',
reldirpath: 'subfolder',