Merge branch 'advplyr:master' into multi-select-keyboard-navigation

This commit is contained in:
Greg Lorenzen 2024-11-06 19:37:26 -08:00 committed by GitHub
commit 588def6d33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 127 additions and 74 deletions

View File

@ -56,7 +56,7 @@ export default {
}, },
imgSrc() { imgSrc() {
if (!this.imagePath) return null if (!this.imagePath) return null
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
} }
}, },
methods: { methods: {

View File

@ -147,7 +147,7 @@ export default class LocalAudioPlayer extends EventEmitter {
timeoutRetry: { timeoutRetry: {
maxNumRetry: 4, maxNumRetry: 4,
retryDelayMs: 0, retryDelayMs: 0,
maxRetryDelayMs: 0, maxRetryDelayMs: 0
}, },
errorRetry: { errorRetry: {
maxNumRetry: 8, maxNumRetry: 8,
@ -160,7 +160,7 @@ export default class LocalAudioPlayer extends EventEmitter {
} }
return retry return retry
} }
}, }
} }
} }
} }
@ -194,7 +194,7 @@ export default class LocalAudioPlayer extends EventEmitter {
setDirectPlay() { setDirectPlay() {
// Set initial track and track time offset // Set initial track and track time offset
var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration)) var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration)
this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0 this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0
this.loadCurrentTrack() this.loadCurrentTrack()
@ -270,7 +270,7 @@ export default class LocalAudioPlayer extends EventEmitter {
// Seeking Direct play // Seeking Direct play
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track // Change Track
var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration)) var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
if (trackIndex >= 0) { if (trackIndex >= 0) {
this.startTime = time this.startTime = time
this.currentTrackIndex = trackIndex this.currentTrackIndex = trackIndex
@ -293,7 +293,6 @@ export default class LocalAudioPlayer extends EventEmitter {
this.player.volume = volume this.player.volume = volume
} }
// Utils // Utils
isValidDuration(duration) { isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {

View File

@ -1,6 +1,6 @@
const SupportedFileTypes = { const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'], image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'], info: ['nfo'],
text: ['txt'], text: ['txt'],
@ -81,9 +81,7 @@ const Hotkeys = {
} }
} }
export { export { Constants }
Constants
}
export default ({ app }, inject) => { export default ({ app }, inject) => {
inject('constants', Constants) inject('constants', Constants)
inject('keynames', KeyNames) inject('keynames', KeyNames)

View File

@ -98,7 +98,7 @@ export const getters = {
const userToken = rootGetters['user/getToken'] const userToken = rootGetters['user/getToken']
const lastUpdate = libraryItem.updatedAt || Date.now() const lastUpdate = libraryItem.updatedAt || Date.now()
const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}`
}, },
getLibraryItemCoverSrcById: getLibraryItemCoverSrcById:
(state, getters, rootState, rootGetters) => (state, getters, rootState, rootGetters) =>
@ -106,7 +106,7 @@ export const getters = {
const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
if (!libraryItemId) return placeholder if (!libraryItemId) return placeholder
const userToken = rootGetters['user/getToken'] const userToken = rootGetters['user/getToken']
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
}, },
getIsBatchSelectingMediaItems: (state) => { getIsBatchSelectingMediaItems: (state) => {
return state.selectedMediaItems.length return state.selectedMediaItems.length

View File

@ -18,6 +18,26 @@ class Auth {
constructor() { constructor() {
// Map of openId sessions indexed by oauth2 state-variable // Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map() this.openIdAuthSession = new Map()
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
}
/**
* Checks if the request should not be authenticated.
* @param {Request} req
* @returns {boolean}
* @private
*/
authNotNeeded(req) {
return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl))
}
ifAuthNeeded(middleware) {
return (req, res, next) => {
if (this.authNotNeeded(req)) {
return next()
}
middleware(req, res, next)
}
} }
/** /**

View File

@ -238,7 +238,7 @@ class Server {
// init passport.js // init passport.js
app.use(passport.initialize()) app.use(passport.initialize())
// register passport in express-session // register passport in express-session
app.use(passport.session()) app.use(this.auth.ifAuthNeeded(passport.session()))
// config passport.js // config passport.js
await this.auth.initPassportJs() await this.auth.initPassportJs()
@ -268,6 +268,10 @@ class Server {
router.use(express.urlencoded({ extended: true, limit: '5mb' })) router.use(express.urlencoded({ extended: true, limit: '5mb' }))
router.use(express.json({ limit: '5mb' })) 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('/public', this.publicRouter.router)
// Static path to generated nuxt // Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist') const distPath = Path.join(global.appRoot, '/client/dist')
router.use(express.static(distPath)) router.use(express.static(distPath))
@ -275,10 +279,6 @@ class Server {
// Static folder // Static folder
router.use(express.static(Path.join(global.appRoot, 'static'))) router.use(express.static(Path.join(global.appRoot, 'static')))
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// RSS Feed temp route // RSS Feed temp route
router.get('/feed/:slug', (req, res) => { router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
@ -296,7 +296,7 @@ class Server {
await this.auth.initAuthRoutes(router) await this.auth.initAuthRoutes(router)
// Client dynamic routes // Client dynamic routes
const dyanimicRoutes = [ const dynamicRoutes = [
'/item/:id', '/item/:id',
'/author/:id', '/author/:id',
'/audiobook/:id/chapters', '/audiobook/:id/chapters',
@ -319,7 +319,7 @@ class Server {
'/playlist/:id', '/playlist/:id',
'/share/:slug' '/share/:slug'
] ]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/init', (req, res) => { router.post('/init', (req, res) => {
if (Database.hasRootUser) { if (Database.hasRootUser) {

View File

@ -381,16 +381,23 @@ class AuthorController {
*/ */
async getImage(req, res) { async getImage(req, res) {
const { const {
query: { width, height, format, raw }, query: { width, height, format, raw }
author
} = req } = req
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { const authorId = req.params.id
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
return res.sendStatus(404)
}
if (raw) { if (raw) {
const author = await Database.authorModel.findByPk(authorId)
if (!author) {
Logger.warn(`[AuthorController] Author "${authorId}" not found`)
return res.sendStatus(404)
}
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
return res.sendStatus(404)
}
return res.sendFile(author.imagePath) return res.sendFile(author.imagePath)
} }
@ -399,7 +406,7 @@ class AuthorController {
height: height ? parseInt(height) : null, height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null width: width ? parseInt(width) : null
} }
return CacheManager.handleAuthorCache(res, author, options) return CacheManager.handleAuthorCache(res, authorId, options)
} }
/** /**

View File

@ -342,44 +342,25 @@ class LibraryItemController {
query: { width, height, format, raw } query: { width, height, format, raw }
} = req } = req
const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, {
attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],
include: [
{
model: Database.bookModel,
attributes: ['id', 'coverPath', 'tags', 'explicit']
},
{
model: Database.podcastModel,
attributes: ['id', 'coverPath', 'tags', 'explicit']
}
]
})
if (!libraryItem) {
Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
// Check if user can access this library item
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
// Check if library item media has a cover path
if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) {
return res.sendStatus(404)
}
if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')
const libraryItemId = req.params.id
if (!libraryItemId) {
return res.sendStatus(400)
}
if (raw) { if (raw) {
const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)
if (!coverPath || !(await fs.pathExists(coverPath))) {
return res.sendStatus(404)
}
// any value // any value
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath) const encodedURI = encodeUriPath(global.XAccel + coverPath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
} }
return res.sendFile(libraryItem.media.coverPath) return res.sendFile(coverPath)
} }
const options = { const options = {
@ -387,7 +368,7 @@ class LibraryItemController {
height: height ? parseInt(height) : null, height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null width: width ? parseInt(width) : null
} }
return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options) return CacheManager.handleCoverCache(res, libraryItemId, options)
} }
/** /**

View File

@ -4,6 +4,7 @@ const stream = require('stream')
const Logger = require('../Logger') const Logger = require('../Logger')
const { resizeImage } = require('../utils/ffmpegHelpers') const { resizeImage } = require('../utils/ffmpegHelpers')
const { encodeUriPath } = require('../utils/fileUtils') const { encodeUriPath } = require('../utils/fileUtils')
const Database = require('../Database')
class CacheManager { class CacheManager {
constructor() { constructor() {
@ -29,24 +30,24 @@ class CacheManager {
await fs.ensureDir(this.ItemCachePath) await fs.ensureDir(this.ItemCachePath)
} }
async handleCoverCache(res, libraryItemId, coverPath, options = {}) { async handleCoverCache(res, libraryItemId, options = {}) {
const format = options.format || 'webp' const format = options.format || 'webp'
const width = options.width || 400 const width = options.width || 400
const height = options.height || null const height = options.height || null
res.type(`image/${format}`) res.type(`image/${format}`)
const path = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists // Cache exists
if (await fs.pathExists(path)) { if (await fs.pathExists(cachePath)) {
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + path) const encodedURI = encodeUriPath(global.XAccel + cachePath)
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
} }
const r = fs.createReadStream(path) const r = fs.createReadStream(cachePath)
const ps = new stream.PassThrough() const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => { stream.pipeline(r, ps, (err) => {
if (err) { if (err) {
@ -57,7 +58,13 @@ class CacheManager {
return ps.pipe(res) return ps.pipe(res)
} }
const writtenFile = await resizeImage(coverPath, path, width, height) // Cached cover does not exist, generate it
const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId)
if (!coverPath || !(await fs.pathExists(coverPath))) {
return res.sendStatus(404)
}
const writtenFile = await resizeImage(coverPath, cachePath, width, height)
if (!writtenFile) return res.sendStatus(500) if (!writtenFile) return res.sendStatus(500)
if (global.XAccel) { if (global.XAccel) {
@ -127,22 +134,22 @@ class CacheManager {
/** /**
* *
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {import('../models/Author')} author * @param {String} authorId
* @param {{ format?: string, width?: number, height?: number }} options * @param {{ format?: string, width?: number, height?: number }} options
* @returns * @returns
*/ */
async handleAuthorCache(res, author, options = {}) { async handleAuthorCache(res, authorId, options = {}) {
const format = options.format || 'webp' const format = options.format || 'webp'
const width = options.width || 400 const width = options.width || 400
const height = options.height || null const height = options.height || null
res.type(`image/${format}`) res.type(`image/${format}`)
var path = Path.join(this.ImageCachePath, `${author.id}_${width}${height ? `x${height}` : ''}`) + '.' + format var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists // Cache exists
if (await fs.pathExists(path)) { if (await fs.pathExists(cachePath)) {
const r = fs.createReadStream(path) const r = fs.createReadStream(cachePath)
const ps = new stream.PassThrough() const ps = new stream.PassThrough()
stream.pipeline(r, ps, (err) => { stream.pipeline(r, ps, (err) => {
if (err) { if (err) {
@ -153,7 +160,12 @@ class CacheManager {
return ps.pipe(res) return ps.pipe(res)
} }
let writtenFile = await resizeImage(author.imagePath, path, width, height) const author = await Database.authorModel.findByPk(authorId)
if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) {
return res.sendStatus(404)
}
let writtenFile = await resizeImage(author.imagePath, cachePath, width, height)
if (!writtenFile) return res.sendStatus(500) if (!writtenFile) return res.sendStatus(500)
var readStream = fs.createReadStream(writtenFile) var readStream = fs.createReadStream(writtenFile)

View File

@ -863,6 +863,33 @@ class LibraryItem extends Model {
return this.getOldLibraryItem(libraryItem) return this.getOldLibraryItem(libraryItem)
} }
/**
*
* @param {string} libraryItemId
* @returns {Promise<string>}
*/
static async getCoverPath(libraryItemId) {
const libraryItem = await this.findByPk(libraryItemId, {
attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],
include: [
{
model: this.sequelize.models.book,
attributes: ['id', 'coverPath']
},
{
model: this.sequelize.models.podcast,
attributes: ['id', 'coverPath']
}
]
})
if (!libraryItem) {
Logger.warn(`[LibraryItem] getCoverPath: Library item "${libraryItemId}" does not exist`)
return null
}
return libraryItem.media.coverPath
}
/** /**
* *
* @param {import('sequelize').FindOptions} options * @param {import('sequelize').FindOptions} options

View File

@ -216,7 +216,7 @@ class ApiRouter {
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this)) this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) this.router.get('/authors/:id/image', AuthorController.getImage.bind(this))
this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this))
this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this))

View File

@ -49,5 +49,7 @@ module.exports.AudioMimeType = {
WEBMA: 'audio/webm', WEBMA: 'audio/webm',
MKA: 'audio/x-matroska', MKA: 'audio/x-matroska',
AWB: 'audio/amr-wb', AWB: 'audio/amr-wb',
CAF: 'audio/x-caf' CAF: 'audio/x-caf',
MPEG: 'audio/mpeg',
MPG: 'audio/mpeg'
} }

View File

@ -1,6 +1,6 @@
const globals = { const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
TextFileTypes: ['txt', 'nfo'], TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] MetadataFileTypes: ['opf', 'abs', 'xml', 'json']

View File

@ -52,6 +52,13 @@ module.exports.parse = (nameString) => {
} }
if (splitNames.length) splitNames = splitNames.map((a) => a.trim()) if (splitNames.length) splitNames = splitNames.map((a) => a.trim())
// If names are in ChineseJapanese and Korean languages, return as is.
if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) {
return {
names: splitNames
}
}
var names = [] var names = []
// 1 name FIRST LAST // 1 name FIRST LAST