mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-05 15:04:56 +02:00
Add:Cover image cache, resize & use webp image #223
This commit is contained in:
parent
d04f3450ec
commit
ddf0fa72e8
14 changed files with 360 additions and 108 deletions
|
@ -5,7 +5,6 @@ const date = require('date-and-time')
|
|||
|
||||
const Logger = require('./Logger')
|
||||
const { isObject } = require('./utils/index')
|
||||
const resize = require('./utils/resizeImage')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
|
||||
const BookController = require('./controllers/BookController')
|
||||
|
@ -19,7 +18,7 @@ const BookFinder = require('./BookFinder')
|
|||
const AuthorFinder = require('./AuthorFinder')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
|
@ -29,6 +28,7 @@ class ApiController {
|
|||
this.backupManager = backupManager
|
||||
this.coverController = coverController
|
||||
this.watcher = watcher
|
||||
this.cacheManager = cacheManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
|
@ -62,12 +62,10 @@ class ApiController {
|
|||
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
|
||||
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
|
||||
|
||||
|
||||
// TEMP: Support old syntax for mobile app
|
||||
this.router.get('/library/:id/audiobooks', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this))
|
||||
this.router.get('/library/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
|
||||
|
||||
|
||||
//
|
||||
// Book Routes
|
||||
//
|
||||
|
@ -83,7 +81,7 @@ class ApiController {
|
|||
this.router.patch('/books/:id/tracks', BookController.updateTracks.bind(this))
|
||||
this.router.get('/books/:id/stream', BookController.openStream.bind(this))
|
||||
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
|
||||
this.router.get('/books/:id/cover', this.resizeCover.bind(this))
|
||||
this.router.get('/books/:id/cover', BookController.getCover.bind(this))
|
||||
this.router.patch('/books/:id/coverfile', BookController.updateCoverFromFile.bind(this))
|
||||
|
||||
// TEMP: Support old syntax for mobile app
|
||||
|
@ -91,7 +89,6 @@ class ApiController {
|
|||
this.router.get('/audiobook/:id', BookController.findOne.bind(this))
|
||||
this.router.get('/audiobook/:id/stream', BookController.openStream.bind(this))
|
||||
|
||||
|
||||
//
|
||||
// User Routes
|
||||
//
|
||||
|
@ -104,7 +101,6 @@ class ApiController {
|
|||
this.router.get('/users/:id/listening-sessions', UserController.getListeningStats.bind(this))
|
||||
this.router.get('/users/:id/listening-stats', UserController.getListeningStats.bind(this))
|
||||
|
||||
|
||||
//
|
||||
// Collection Routes
|
||||
//
|
||||
|
@ -123,7 +119,6 @@ class ApiController {
|
|||
this.router.get('/collection/:id', CollectionController.findOne.bind(this))
|
||||
this.router.delete('/collection/:id/book/:bookId', CollectionController.removeBook.bind(this))
|
||||
|
||||
|
||||
//
|
||||
// Current User Routes (Me)
|
||||
//
|
||||
|
@ -140,7 +135,6 @@ class ApiController {
|
|||
this.router.patch('/user/audiobook/:id', MeController.updateAudiobookData.bind(this))
|
||||
this.router.patch('/user/settings', MeController.updateSettings.bind(this))
|
||||
|
||||
|
||||
//
|
||||
// Backup Routes
|
||||
//
|
||||
|
@ -176,31 +170,10 @@ class ApiController {
|
|||
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
|
||||
|
||||
this.router.post('/syncUserAudiobookData', this.syncUserAudiobookData.bind(this))
|
||||
|
||||
this.router.post('/purgecache', this.purgeCache.bind(this))
|
||||
}
|
||||
|
||||
async resizeCover(req, res) {
|
||||
let { query: { width, height }, params: { id }, user } = req;
|
||||
if (!user) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this audiobooks library
|
||||
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
|
||||
res.type('image/jpeg');
|
||||
|
||||
if (width) width = parseInt(width)
|
||||
if (height) height = parseInt(height)
|
||||
|
||||
return resize(audiobook.book.coverFullPath, width, height).pipe(res)
|
||||
}
|
||||
|
||||
|
||||
async findBooks(req, res) {
|
||||
var provider = req.query.provider || 'google'
|
||||
var title = req.query.title || ''
|
||||
|
@ -485,6 +458,11 @@ class ApiController {
|
|||
this.clientEmitter(collection.userId, 'collection_updated', collection.toJSONExpanded(this.db.audiobooks))
|
||||
}
|
||||
|
||||
// purge cover cache
|
||||
if (audiobook.cover) {
|
||||
await this.cacheManager.purgeCoverCache(audiobook.id)
|
||||
}
|
||||
|
||||
var audiobookJSON = audiobook.toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', audiobook.id)
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
|
@ -527,5 +505,14 @@ class ApiController {
|
|||
})
|
||||
return listeningStats
|
||||
}
|
||||
|
||||
async purgeCache(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[ApiController] Purging all cache`)
|
||||
await this.cacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
}
|
||||
module.exports = ApiController
|
75
server/CacheManager.js
Normal file
75
server/CacheManager.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const stream = require('stream')
|
||||
const resize = require('./utils/resizeImage')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class CacheManager {
|
||||
constructor(MetadataPath) {
|
||||
this.MetadataPath = MetadataPath
|
||||
this.CachePath = Path.join(this.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
}
|
||||
|
||||
async handleCoverCache(res, audiobook, options = {}) {
|
||||
const format = options.format || 'webp'
|
||||
const width = options.width || 400
|
||||
const height = options.height || null
|
||||
|
||||
res.type(`image/${format}`)
|
||||
|
||||
var path = Path.join(this.CoverCachePath, audiobook.id) + '.' + format
|
||||
|
||||
// Cache exists
|
||||
if (await fs.pathExists(path)) {
|
||||
const r = fs.createReadStream(path)
|
||||
const ps = new stream.PassThrough()
|
||||
stream.pipeline(r, ps, (err) => {
|
||||
if (err) {
|
||||
console.log(err)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
})
|
||||
return ps.pipe(res)
|
||||
}
|
||||
|
||||
// Write cache
|
||||
await fs.ensureDir(this.CoverCachePath)
|
||||
var readStream = resize(audiobook.book.coverFullPath, width, height, format)
|
||||
var writeStream = fs.createWriteStream(path)
|
||||
writeStream.on('error', (e) => {
|
||||
Logger.error(`[CacheManager] Cache write error ${e.message}`)
|
||||
})
|
||||
readStream.pipe(writeStream)
|
||||
|
||||
readStream.pipe(res)
|
||||
}
|
||||
|
||||
purgeCoverCache(audiobookId) {
|
||||
var basepath = Path.join(this.CoverCachePath, audiobookId)
|
||||
// Remove both webp and jpg caches if exist
|
||||
var webpPath = basepath + '.webp'
|
||||
var jpgPath = basepath + '.jpg'
|
||||
return Promise.all([this.removeCache(webpPath), this.removeCache(jpgPath)])
|
||||
}
|
||||
|
||||
removeCache(path) {
|
||||
if (!path) return false
|
||||
return fs.pathExists(path).then((exists) => {
|
||||
if (!exists) return false
|
||||
return fs.unlink(path).then(() => true).catch((err) => {
|
||||
Logger.error(`[CacheManager] Failed to remove cache "${path}"`, err)
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async purgeAll() {
|
||||
if (await fs.pathExists(this.CachePath)) {
|
||||
await fs.remove(this.CachePath).catch((error) => {
|
||||
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CacheManager
|
|
@ -10,8 +10,10 @@ const { CoverDestination } = require('./utils/constants')
|
|||
const { downloadFile } = require('./utils/fileUtils')
|
||||
|
||||
class CoverController {
|
||||
constructor(db, MetadataPath, AudiobookPath) {
|
||||
constructor(db, cacheManager, MetadataPath, AudiobookPath) {
|
||||
this.db = db
|
||||
this.cacheManager = cacheManager
|
||||
|
||||
this.MetadataPath = MetadataPath.replace(/\\/g, '/')
|
||||
this.BookMetadataPath = Path.posix.join(this.MetadataPath, 'books')
|
||||
this.AudiobookPath = AudiobookPath
|
||||
|
@ -115,6 +117,7 @@ class CoverController {
|
|||
}
|
||||
|
||||
await this.removeOldCovers(fullPath, extname)
|
||||
await this.cacheManager.purgeCoverCache(audiobook.id)
|
||||
|
||||
Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
|
@ -152,6 +155,7 @@ class CoverController {
|
|||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(fullPath, '.' + imgtype.ext)
|
||||
await this.cacheManager.purgeCoverCache(audiobook.id)
|
||||
|
||||
Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ const StreamManager = require('./StreamManager')
|
|||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const CoverController = require('./CoverController')
|
||||
const CacheManager = require('./CacheManager')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
|
@ -47,15 +48,16 @@ class Server {
|
|||
this.auth = new Auth(this.db)
|
||||
this.backupManager = new BackupManager(this.MetadataPath, this.Uid, this.Gid, this.db)
|
||||
this.logManager = new LogManager(this.MetadataPath, this.db)
|
||||
this.cacheManager = new CacheManager(this.MetadataPath)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
this.coverController = new CoverController(this.db, this.cacheManager, this.MetadataPath, this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
this.scanner2 = new Scanner2(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
Logger.logManager = this.logManager
|
||||
|
|
|
@ -72,7 +72,7 @@ class StreamManager {
|
|||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups' && dirname !== 'logs') {
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups' && dirname !== 'logs' && dirname !== 'cache') {
|
||||
var fullPath = Path.join(this.MetadataPath, dirname)
|
||||
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
|
||||
class BookController {
|
||||
constructor() { }
|
||||
|
@ -38,6 +39,12 @@ class BookController {
|
|||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
// Book has cover and update is removing cover then purge cache
|
||||
if (audiobook.cover && req.body.book && (req.body.book.cover === '' || req.body.book.cover === null)) {
|
||||
await this.cacheManager.purgeCoverCache(audiobook.id)
|
||||
}
|
||||
|
||||
var hasUpdates = audiobook.update(req.body)
|
||||
if (hasUpdates) {
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
|
@ -222,5 +229,24 @@ class BookController {
|
|||
if (updated) res.status(200).send('Cover updated successfully')
|
||||
else res.status(200).send('No update was made to cover')
|
||||
}
|
||||
|
||||
// GET api/books/:id/cover
|
||||
async getCover(req, res) {
|
||||
let { query: { width, height, format }, params: { id } } = req
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === id)
|
||||
if (!audiobook || !audiobook.book.coverFullPath) return res.sendStatus(404)
|
||||
|
||||
// Check user can access this audiobooks library
|
||||
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const options = {
|
||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpg'),
|
||||
height: height ? parseInt(height) : null,
|
||||
width: width ? parseInt(width) : null
|
||||
}
|
||||
return this.cacheManager.handleCoverCache(res, audiobook, options)
|
||||
}
|
||||
}
|
||||
module.exports = new BookController()
|
|
@ -101,4 +101,9 @@ function secondsToTimestamp(seconds, includeMs = false) {
|
|||
}
|
||||
module.exports.secondsToTimestamp = secondsToTimestamp
|
||||
|
||||
module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000, includeMs)
|
||||
module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000, includeMs)
|
||||
|
||||
module.exports.reqSupportsWebp = (req) => {
|
||||
if (!req || !req.headers || !req.headers.accept) return false
|
||||
return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*'
|
||||
}
|
|
@ -1,13 +1,13 @@
|
|||
const sharp = require('sharp')
|
||||
const fs = require('fs')
|
||||
|
||||
function resize(filePath, width, height) {
|
||||
function resize(filePath, width, height, format = 'webp') {
|
||||
const readStream = fs.createReadStream(filePath);
|
||||
let sharpie = sharp()
|
||||
sharpie.toFormat('jpeg')
|
||||
sharpie.toFormat(format)
|
||||
|
||||
if (width || height) {
|
||||
sharpie.resize(width, height)
|
||||
sharpie.resize(width, height, { withoutEnlargement: true })
|
||||
}
|
||||
|
||||
return readStream.pipe(sharpie)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue