New api routes, updating web client pages, audiobooks to libraryItem migration

This commit is contained in:
advplyr 2022-03-10 18:45:02 -06:00
parent b97ed953f7
commit 2a30cc428f
51 changed files with 1225 additions and 654 deletions

View file

@ -14,6 +14,7 @@ const UserController = require('./controllers/UserController')
const CollectionController = require('./controllers/CollectionController')
const MeController = require('./controllers/MeController')
const BackupController = require('./controllers/BackupController')
const LibraryItemController = require('./controllers/LibraryItemController')
const BookFinder = require('./finders/BookFinder')
const AuthorFinder = require('./finders/AuthorFinder')
@ -53,19 +54,29 @@ class ApiController {
this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary2.bind(this))
this.router.get('/libraries/:id/books', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this))
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:series', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this))
this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
// Legacy
this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this))
this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this))
this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this))
//
// Item Routes
//
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
//
// Book Routes
//

View file

@ -10,14 +10,14 @@ class CacheManager {
this.CoverCachePath = Path.join(this.CachePath, 'covers')
}
async handleCoverCache(res, audiobook, options = {}) {
async handleCoverCache(res, libraryItem, 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}_${width}${height ? `x${height}` : ''}`) + '.' + format
var path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(path)) {
@ -35,7 +35,7 @@ class CacheManager {
// Write cache
await fs.ensureDir(this.CoverCachePath)
let writtenFile = await resizeImage(audiobook.book.coverFullPath, path, width, height)
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(400)
var readStream = fs.createReadStream(writtenFile)

View file

@ -69,7 +69,6 @@ class Server {
Logger.logManager = this.logManager
this.expressApp = null
this.server = null
this.io = null
@ -154,8 +153,6 @@ class Server {
await this.init()
const app = express()
this.expressApp = app
this.server = http.createServer(app)
app.use(this.auth.cors)
@ -167,9 +164,6 @@ class Server {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath))
// Old static path for covers
app.use('/local', this.authMiddleware.bind(this), express.static(global.AudiobookPath))
// Metadata folder static path
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
@ -179,6 +173,9 @@ class Server {
// Static folder
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// Static file routes
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
@ -192,6 +189,7 @@ class Server {
})
// Book static file routes
// LEGACY
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
@ -201,6 +199,16 @@ class Server {
res.sendFile(fullPath)
})
// Library Item static file routes
app.get('/s/item/:id/*', this.authMiddleware.bind(this), (req, res) => {
var item = this.db.libraryItems.find(ab => ab.id === req.params.id)
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = Path.join(item.path, remainingPath)
res.sendFile(fullPath)
})
// EBook static file routes
app.get('/ebook/:library/:folder/*', (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
@ -214,8 +222,10 @@ class Server {
})
// Client dynamic routes
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/item/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
@ -224,31 +234,16 @@ class Server {
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// Incomplete work in progress
// app.use('/feeds', this.rssFeeds.router)
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
var loginRateLimiter = this.getLoginRateLimiter()
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.get('/ping', (req, res) => {
Logger.info('Recieved ping')
res.json({ success: true })
})
// Used in development to set-up streams without authentication
if (process.env.NODE_ENV !== 'production') {
app.use('/test-hls', this.hlsController.router)
}
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`)
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
})
this.io = new SocketIO.Server(this.server, {
@ -265,21 +260,25 @@ class Server {
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[SOCKET] Socket Connected', socket.id)
Logger.info('[Server] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// TODO: Most of these web socket listeners will be moved to API routes instead
// with the goal of the web socket connection being a nice-to-have not need-to-have
// Scanning
socket.on('scan', this.scan.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
// Streaming
// Streaming (only still used in the mobile app)
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
// Used to sync when playing local book on mobile, will be moved to API route
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
// Downloading
@ -299,10 +298,6 @@ class Server {
socket.on('update_bookmark', (payload) => this.updateBookmark(socket, payload))
socket.on('delete_bookmark', (payload) => this.deleteBookmark(socket, payload))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
socket.on('disconnect', () => {
Logger.removeSocketListener(socket.id)

View file

@ -39,10 +39,9 @@ class LibraryController {
async findOne(req, res) {
if (req.query.include && req.query.include === 'filterdata') {
var books = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id)
return res.json({
filterdata: libraryHelpers.getDistinctFilterData(books),
issues: libraryHelpers.getNumIssues(books),
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
issues: libraryHelpers.getNumIssues(req.libraryItems),
library: req.library
})
}
@ -91,38 +90,70 @@ class LibraryController {
return res.json(libraryJson)
}
// api/libraries/:id/books
getBooksForLibrary(req, res) {
// api/libraries/:id/items
// TODO: Optimize this method, audiobooks are iterated through several times but can be combined
getLibraryItems(req, res) {
var libraryId = req.library.id
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
if (req.query.filter) {
audiobooks = libraryHelpers.getFiltered(audiobooks, req.query.filter, req.user)
var media = req.query.media || 'all'
var libraryItems = this.db.libraryItems.filter(li => {
if (li.libraryId !== libraryId) return false
if (media != 'all') return li.mediaType == media
return true
})
var payload = {
results: [],
total: libraryItems.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
media,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1'
}
if (req.query.sort) {
var orderByNumber = req.query.sort === 'book.volumeNumber'
var direction = req.query.desc === '1' ? 'desc' : 'asc'
audiobooks = sort(audiobooks)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
var value = req.query.sort.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
payload.total = libraryItems.length
}
if (payload.sortBy) {
var sortKey = payload.sortBy
// old sort key
if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.')
}
// Handle server setting sortingIgnorePrefix
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix'
}
var direction = payload.sortDesc ? 'desc' : 'asc'
libraryItems = naturalSort(libraryItems)[direction]((li) => {
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
})
}
if (req.query.limit && !isNaN(req.query.limit)) {
var page = req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
var limit = Number(req.query.limit)
var startIndex = page * limit
audiobooks = audiobooks.slice(startIndex, startIndex + limit)
// TODO: Potentially implement collapse series again
libraryItems = libraryItems.map(ab => payload.minified ? ab.toJSONMinified() : ab.toJSON())
if (payload.limit) {
var startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
res.json(audiobooks)
payload.results = libraryItems
res.json(payload)
}
// api/libraries/:id/books/all
// TODO: Optimize this method, audiobooks are iterated through several times but can be combined
getBooksForLibrary2(req, res) {
getBooksForLibrary(req, res) {
var libraryId = req.library.id
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
@ -274,6 +305,7 @@ class LibraryController {
res.json(payload)
}
// LEGACY
// api/libraries/:id/books/filters
async getLibraryFilters(req, res) {
var library = req.library
@ -281,6 +313,45 @@ class LibraryController {
res.json(libraryHelpers.getDistinctFilterData(books))
}
async getLibraryFilterData(req, res) {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
}
// api/libraries/:id/books/personalized
async getLibraryUserPersonalized(req, res) {
var libraryItems = req.libraryItems
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
var minified = req.query.minified === '1'
var booksWithUserAb = libraryHelpers.getBooksWithUserAudiobook(req.user, libraryItems)
var categories = [
{
id: 'continue-reading',
label: 'Continue Reading',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf, minified)
},
{
id: 'recently-added',
label: 'Recently Added',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyAdded(libraryItems, limitPerShelf, minified)
},
{
id: 'read-again',
label: 'Read Again',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf, minified)
}
].filter(cats => { // Remove categories with no items
return cats.entities.length
})
res.json(categories)
}
// LEGACY
// api/libraries/:id/books/categories
async getLibraryCategories(req, res) {
var library = req.library
@ -491,6 +562,7 @@ class LibraryController {
return res.status(404).send('Library not found')
}
req.library = library
req.libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
next()
}
}

View file

@ -0,0 +1,37 @@
const Logger = require('../Logger')
const { reqSupportsWebp } = require('../utils/index')
class LibraryItemController {
constructor() { }
findOne(req, res) {
if (req.query.expanded == 1) return res.json(req.libraryItem.toJSONExpanded())
res.json(req.libraryItem)
}
// GET api/items/:id/cover
async getCover(req, res) {
let { query: { width, height, format }, libraryItem } = req
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
return this.cacheManager.handleCoverCache(res, libraryItem, options)
}
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media || !item.media.coverPath) return res.sendStatus(404)
// Check user can access this audiobooks library
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
}
module.exports = new LibraryItemController()

View file

@ -1,7 +1,7 @@
const fs = require('fs-extra')
const Logger = require('../Logger')
const Path = require('path')
const Author = require('../objects/Author')
const Author = require('../objects/legacy/Author')
const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils')

View file

@ -1,129 +0,0 @@
class AudioFileMetadata {
constructor(metadata) {
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagSeries = null
this.tagSeriesPart = null
this.tagTrack = null
this.tagDisc = null
this.tagSubtitle = null
this.tagAlbumArtist = null
this.tagDate = null
this.tagComposer = null
this.tagPublisher = null
this.tagComment = null
this.tagDescription = null
this.tagEncoder = null
this.tagEncodedBy = null
this.tagIsbn = null
this.tagLanguage = null
this.tagASIN = null
if (metadata) {
this.construct(metadata)
}
}
toJSON() {
// Only return the tags that are actually set
var json = {}
for (const key in this) {
if (key.startsWith('tag') && this[key]) {
json[key] = this[key]
}
}
return json
}
construct(metadata) {
this.tagAlbum = metadata.tagAlbum || null
this.tagArtist = metadata.tagArtist || null
this.tagGenre = metadata.tagGenre || null
this.tagTitle = metadata.tagTitle || null
this.tagSeries = metadata.tagSeries || null
this.tagSeriesPart = metadata.tagSeriesPart || null
this.tagTrack = metadata.tagTrack || null
this.tagDisc = metadata.tagDisc || null
this.tagSubtitle = metadata.tagSubtitle || null
this.tagAlbumArtist = metadata.tagAlbumArtist || null
this.tagDate = metadata.tagDate || null
this.tagComposer = metadata.tagComposer || null
this.tagPublisher = metadata.tagPublisher || null
this.tagComment = metadata.tagComment || null
this.tagDescription = metadata.tagDescription || null
this.tagEncoder = metadata.tagEncoder || null
this.tagEncodedBy = metadata.tagEncodedBy || null
this.tagIsbn = metadata.tagIsbn || null
this.tagLanguage = metadata.tagLanguage || null
this.tagASIN = metadata.tagASIN || null
}
// Data parsed in prober.js
setData(payload) {
this.tagAlbum = payload.file_tag_album || null
this.tagArtist = payload.file_tag_artist || null
this.tagGenre = payload.file_tag_genre || null
this.tagTitle = payload.file_tag_title || null
this.tagSeries = payload.file_tag_series || null
this.tagSeriesPart = payload.file_tag_seriespart || null
this.tagTrack = payload.file_tag_track || null
this.tagDisc = payload.file_tag_disc || null
this.tagSubtitle = payload.file_tag_subtitle || null
this.tagAlbumArtist = payload.file_tag_albumartist || null
this.tagDate = payload.file_tag_date || null
this.tagComposer = payload.file_tag_composer || null
this.tagPublisher = payload.file_tag_publisher || null
this.tagComment = payload.file_tag_comment || null
this.tagDescription = payload.file_tag_description || null
this.tagEncoder = payload.file_tag_encoder || null
this.tagEncodedBy = payload.file_tag_encodedby || null
this.tagIsbn = payload.file_tag_isbn || null
this.tagLanguage = payload.file_tag_language || null
this.tagASIN = payload.file_tag_asin || null
}
updateData(payload) {
const dataMap = {
tagAlbum: payload.file_tag_album || null,
tagArtist: payload.file_tag_artist || null,
tagGenre: payload.file_tag_genre || null,
tagTitle: payload.file_tag_title || null,
tagSeries: payload.file_tag_series || null,
tagSeriesPart: payload.file_tag_seriespart || null,
tagTrack: payload.file_tag_track || null,
tagDisc: payload.file_tag_disc || null,
tagSubtitle: payload.file_tag_subtitle || null,
tagAlbumArtist: payload.file_tag_albumartist || null,
tagDate: payload.file_tag_date || null,
tagComposer: payload.file_tag_composer || null,
tagPublisher: payload.file_tag_publisher || null,
tagComment: payload.file_tag_comment || null,
tagDescription: payload.file_tag_description || null,
tagEncoder: payload.file_tag_encoder || null,
tagEncodedBy: payload.file_tag_encodedby || null,
tagIsbn: payload.file_tag_isbn || null,
tagLanguage: payload.file_tag_language || null,
tagASIN: payload.file_tag_asin || null
}
var hasUpdates = false
for (const key in dataMap) {
if (dataMap[key] !== this[key]) {
this[key] = dataMap[key]
hasUpdates = true
}
}
return hasUpdates
}
isEqual(audioFileMetadata) {
if (!audioFileMetadata || !audioFileMetadata.toJSON) return false
for (const key in audioFileMetadata.toJSON()) {
if (audioFileMetadata[key] !== this[key]) return false
}
return true
}
}
module.exports = AudioFileMetadata

View file

@ -17,15 +17,15 @@ class LibraryItem {
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.lastUpdate = null
this.updatedAt = null
this.lastScan = null
this.scanVersion = null
// Entity was scanned and not found
this.isMissing = false
this.entityType = null
this.entity = null
this.mediaType = null
this.media = null
this.libraryFiles = []
@ -45,17 +45,17 @@ class LibraryItem {
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
this.addedAt = libraryItem.addedAt
this.lastUpdate = libraryItem.lastUpdate || this.addedAt
this.updatedAt = libraryItem.updatedAt || this.addedAt
this.lastScan = libraryItem.lastScan || null
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.entityType = libraryItem.entityType
if (this.entityType === 'book') {
this.entity = new Book(libraryItem.entity)
} else if (this.entityType === 'podcast') {
this.entity = new Podcast(libraryItem.entity)
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
}
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
@ -73,14 +73,64 @@ class LibraryItem {
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
entityType: this.entityType,
entity: this.entity.toJSON(),
mediaType: this.mediaType,
media: this.media.toJSON(),
libraryFiles: this.libraryFiles.map(f => f.toJSON())
}
}
toJSONMinified() {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length
}
}
// Adds additional helpful fields like media duration, tracks, etc.
toJSONExpanded() {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
mediaType: this.mediaType,
media: this.media.toJSONExpanded(),
libraryFiles: this.libraryFiles.map(f => f.toJSON()),
size: this.size
}
}
get size() {
var total = 0
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
return total
}
}
module.exports = LibraryItem

View file

@ -7,7 +7,6 @@ class Book {
this.metadata = null
this.coverPath = null
this.relCoverPath = null
this.tags = []
this.audioFiles = []
this.ebookFiles = []
@ -21,7 +20,6 @@ class Book {
construct(book) {
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
this.relCoverPath = book.relCoverPath
this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map(f => new AudioFile(f))
this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f))
@ -32,12 +30,53 @@ class Book {
return {
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
relCoverPath: this.relCoverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c }))
}
}
toJSONMinified() {
return {
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
numTracks: this.tracks.length,
numAudioFiles: this.audioFiles.length,
numEbooks: this.ebookFiles.length,
numChapters: this.chapters.length,
duration: this.duration,
size: this.size
}
}
toJSONExpanded() {
return {
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })),
duration: this.duration,
size: this.size,
tracks: this.tracks.map(t => t.toJSON())
}
}
get tracks() {
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
}
get duration() {
var total = 0
this.tracks.forEach((track) => total += track.duration)
return total
}
get size() {
var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size)
return total
}
}
module.exports = Book

View file

@ -6,8 +6,8 @@ class Podcast {
this.id = null
this.metadata = null
this.cover = null
this.coverFullPath = null
this.coverPath = null
this.tags = []
this.episodes = []
this.createdAt = null
@ -21,8 +21,8 @@ class Podcast {
construct(podcast) {
this.id = podcast.id
this.metadata = new PodcastMetadata(podcast.metadata)
this.cover = podcast.cover
this.coverFullPath = podcast.coverFullPath
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e))
this.createdAt = podcast.createdAt
this.lastUpdate = podcast.lastUpdate
@ -32,8 +32,32 @@ class Podcast {
return {
id: this.id,
metadata: this.metadata.toJSON(),
cover: this.cover,
coverFullPath: this.coverFullPath,
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
toJSONExpanded() {
return {
id: this.id,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate

View file

@ -1,3 +1,4 @@
const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata')
class LibraryFile {
@ -24,8 +25,18 @@ class LibraryFile {
ino: this.ino,
metadata: this.metadata.toJSON(),
addedAt: this.addedAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
fileType: this.fileType
}
}
get fileType() {
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
return 'unknown'
}
}
module.exports = LibraryFile

View file

@ -1,5 +1,5 @@
const { isNullOrNaN } = require('../utils/index')
const AudioFileMetadata = require('./metadata/AudioMetaTags')
const { isNullOrNaN } = require('../../utils/index')
const AudioFileMetadata = require('../metadata/AudioMetaTags')
class AudioFile {
constructor(data) {

View file

@ -1,4 +1,4 @@
var { bytesPretty } = require('../utils/fileUtils')
var { bytesPretty } = require('../../utils/fileUtils')
class AudioTrack {
constructor(audioTrack = null) {

View file

@ -1,12 +1,12 @@
const Path = require('path')
const fs = require('fs-extra')
const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils')
const { comparePaths, getId, elapsedPretty } = require('../utils/index')
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const nfoGenerator = require('../utils/nfoGenerator')
const abmetadataGenerator = require('../utils/abmetadataGenerator')
const Logger = require('../Logger')
const { bytesPretty, readTextFile, getIno } = require('../../utils/fileUtils')
const { comparePaths, getId, elapsedPretty } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata')
const { extractCoverArt } = require('../../utils/ffmpegHelpers')
const nfoGenerator = require('../../utils/nfoGenerator')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const Logger = require('../../Logger')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
const AudioFile = require('./AudioFile')

View file

@ -1,5 +1,5 @@
const { getId } = require('../utils/index')
const Logger = require('../Logger')
const { getId } = require('../../utils/index')
const Logger = require('../../Logger')
class Author {
constructor(author = null) {

View file

@ -1,6 +1,6 @@
const Path = require('path')
const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors')
const Logger = require('../../Logger')
const parseAuthors = require('../../utils/parseAuthors')
class Book {
constructor(book = null) {

View file

@ -13,6 +13,7 @@ class BookMetadata {
this.isbn = null
this.asin = null
this.language = null
this.explicit = false
if (metadata) {
this.construct(metadata)
@ -33,6 +34,7 @@ class BookMetadata {
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = metadata.explicit
}
toJSON() {
@ -49,8 +51,54 @@ class BookMetadata {
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language
language: this.language,
explicit: this.explicit
}
}
toJSONExpanded() {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map(s => ({ ...s })),
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
authorName: this.authorName,
narratorName: this.narratorName
}
}
get titleIgnorePrefix() {
if (!this.title) return ''
if (this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
}
get authorName() {
return this.authors.map(au => au.name).join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
hasAuthor(authorName) {
return !!this.authors.find(au => au.name == authorName)
}
hasSeries(seriesName) {
return !!this.series.find(se => se.name == seriesName)
}
hasNarrator(narratorName) {
return this.narrators.includes(narratorName)
}
}
module.exports = BookMetadata

View file

@ -41,5 +41,10 @@ class FileMetadata {
clone() {
return new FileMetadata(this.toJSON())
}
get format() {
if (!this.ext) return ''
return this.ext.slice(1)
}
}
module.exports = FileMetadata

View file

@ -9,6 +9,7 @@ class PodcastMetadata {
this.itunesPageUrl = null
this.itunesId = null
this.itunesArtistId = null
this.explicit = false
if (metadata) {
this.construct(metadata)
@ -25,6 +26,7 @@ class PodcastMetadata {
this.itunesPageUrl = metadata.itunesPageUrl
this.itunesId = metadata.itunesId
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
}
toJSON() {
@ -38,7 +40,12 @@ class PodcastMetadata {
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit
}
}
toJSONExpanded() {
return this.toJSON()
}
}
module.exports = PodcastMetadata

View file

@ -1,4 +1,4 @@
const AudioFileMetadata = require('../objects/AudioFileMetadata')
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
class AudioProbeData {
constructor() {

View file

@ -10,7 +10,7 @@ const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
const BookFinder = require('../finders/BookFinder')
const Audiobook = require('../objects/Audiobook')
const Audiobook = require('../objects/legacy/Audiobook')
const LibraryScan = require('./LibraryScan')
const ScanOptions = require('./ScanOptions')

View file

@ -3,7 +3,7 @@ const fs = require('fs-extra')
const njodb = require("njodb")
const { SupportedEbookTypes } = require('./globals')
const Audiobook = require('../objects/Audiobook')
const Audiobook = require('../objects/legacy/Audiobook')
const LibraryItem = require('../objects/LibraryItem')
const Logger = require('../Logger')
@ -142,7 +142,7 @@ function makeLibraryItemFromOldAb(audiobook) {
libraryItem.lastScan = audiobook.lastScan
libraryItem.scanVersion = audiobook.scanVersion
libraryItem.isMissing = audiobook.isMissing
libraryItem.entityType = 'book'
libraryItem.mediaType = 'book'
var bookEntity = new Book()
var bookMetadata = new BookMetadata(audiobook.book)
@ -159,8 +159,6 @@ function makeLibraryItemFromOldAb(audiobook) {
bookEntity.metadata = bookMetadata
bookEntity.coverPath = audiobook.book.coverFullPath
// Path relative to library item
bookEntity.relCoverPath = getRelativePath(audiobook.book.coverFullPath, audiobook.fullPath)
bookEntity.tags = [...audiobook.tags]
var payload = makeFilesFromOldAb(audiobook)
@ -171,7 +169,7 @@ function makeLibraryItemFromOldAb(audiobook) {
bookEntity.chapters = audiobook.chapters.map(c => ({ ...c }))
}
libraryItem.entity = bookEntity
libraryItem.media = bookEntity
libraryItem.libraryFiles = payload.libraryFiles
return libraryItem
}

View file

@ -1,7 +1,9 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz']
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs']
}
module.exports = globals

View file

@ -8,6 +8,45 @@ module.exports = {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
getFilteredLibraryItems(libraryItems, filterBy, user) {
var filtered = libraryItems
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(li => li.media.metadata && !li.media.metadata.series.length)
else filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasSeries(filter))
}
else if (group === 'authors') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var userAudiobook = user.getAudiobookJSON(li.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(ab => {
// TODO: Update filter for issues
return ab.isMissing
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
})
}
return filtered
},
getFiltered(audiobooks, filterBy, user) {
var filtered = audiobooks
@ -45,6 +84,55 @@ module.exports = {
return filtered
},
getDistinctFilterDataNew(libraryItems) {
var data = {
authors: [],
genres: [],
tags: [],
series: [],
narrators: [],
languages: []
}
libraryItems.forEach((li) => {
var mediaMetadata = li.media.metadata
if (mediaMetadata.authors.length) {
mediaMetadata.authors.forEach((author) => {
if (author && !data.authors.includes(author.name)) data.authors.push(author.name)
})
}
if (mediaMetadata.series.length) {
mediaMetadata.series.forEach((series) => {
if (series && !data.series.includes(series.name)) data.series.push(series.name)
})
}
if (mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
})
}
if (li.media.tags.length) {
li.media.tags.forEach((tag) => {
if (tag && !data.tags.includes(tag)) data.tags.push(tag)
})
}
if (mediaMetadata.narrators.length) {
mediaMetadata.narrators.forEach((narrator) => {
if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
})
}
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language)
})
data.authors = naturalSort(data.authors).asc()
data.genres = naturalSort(data.genres).asc()
data.tags = naturalSort(data.tags).asc()
data.series = naturalSort(data.series).asc()
data.narrators = naturalSort(data.narrators).asc()
data.languages = naturalSort(data.languages).asc()
return data
},
// TODO: Remove legacy
getDistinctFilterData(audiobooks) {
var data = {
authors: [],
@ -246,9 +334,11 @@ module.exports = {
return totalSize
},
getNumIssues(books) {
return books.filter(ab => {
return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
}).length
getNumIssues(libraryItems) {
// TODO: Implement issues
return libraryItems.filter(li => li.isMissing).length
// return books.filter(ab => {
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
// }).length
}
}