Merge remote-tracking branch 'origin/master' into auth_passportjs

This commit is contained in:
lukeIam 2023-08-12 16:44:44 +02:00
commit dd9a3858d7
249 changed files with 15582 additions and 7835 deletions

View file

@ -185,14 +185,15 @@ class Auth {
} else {
this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
}
await this.db.updateServerSettings()
await Database.updateServerSettings()
// New token secret creation added in v2.1.0 so generate new API tokens for each user
if (this.db.users.length) {
for (const user of this.db.users) {
const users = await Database.models.user.getOldUsers()
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
}
await this.db.updateEntities('user', this.db.users)
await Database.updateBulkUsers(users)
}
}
@ -271,8 +272,9 @@ class Auth {
getUserLoginResponsePayload(user) {
return {
user: user.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
serverSettings: this.db.serverSettings.toJSONForBrowser(),
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
serverSettings: Database.serverSettings.toJSONForBrowser(),
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
Source: global.Source
}
}

549
server/Database.js Normal file
View file

@ -0,0 +1,549 @@
const Path = require('path')
const { Sequelize } = require('sequelize')
const packageJson = require('../package.json')
const fs = require('./libs/fsExtra')
const Logger = require('./Logger')
const dbMigration = require('./utils/migrations/dbMigration')
const Auth = require('./Auth')
class Database {
constructor() {
this.sequelize = null
this.dbPath = null
this.isNew = false // New absdatabase.sqlite created
this.hasRootUser = false // Used to show initialization page in web ui
// Temporarily using format of old DB
// TODO: below data should be loaded from the DB as needed
this.libraryItems = []
this.settings = []
this.authors = []
this.series = []
this.serverSettings = null
this.notificationSettings = null
this.emailSettings = null
}
get models() {
return this.sequelize?.models || {}
}
async checkHasDb() {
if (!await fs.pathExists(this.dbPath)) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
return false
}
return true
}
async init(force = false) {
this.dbPath = Path.join(global.ConfigPath, 'absdatabase.sqlite')
// First check if this is a new database
this.isNew = !(await this.checkHasDb()) || force
if (!await this.connect()) {
throw new Error('Database connection failed')
}
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
await this.loadData()
}
async connect() {
Logger.info(`[Database] Initializing db at "${this.dbPath}"`)
this.sequelize = new Sequelize({
dialect: 'sqlite',
storage: this.dbPath,
logging: false,
transactionType: 'IMMEDIATE'
})
// Helper function
this.sequelize.uppercaseFirst = str => str ? `${str[0].toUpperCase()}${str.substr(1)}` : ''
try {
await this.sequelize.authenticate()
Logger.info(`[Database] Db connection was successful`)
return true
} catch (error) {
Logger.error(`[Database] Failed to connect to db`, error)
return false
}
}
async disconnect() {
Logger.info(`[Database] Disconnecting sqlite db`)
await this.sequelize.close()
this.sequelize = null
}
async reconnect() {
Logger.info(`[Database] Reconnecting sqlite db`)
await this.init()
}
buildModels(force = false) {
require('./models/User')(this.sequelize)
require('./models/Library')(this.sequelize)
require('./models/LibraryFolder')(this.sequelize)
require('./models/Book')(this.sequelize)
require('./models/Podcast')(this.sequelize)
require('./models/PodcastEpisode')(this.sequelize)
require('./models/LibraryItem')(this.sequelize)
require('./models/MediaProgress')(this.sequelize)
require('./models/Series')(this.sequelize)
require('./models/BookSeries')(this.sequelize)
require('./models/Author')(this.sequelize)
require('./models/BookAuthor')(this.sequelize)
require('./models/Collection')(this.sequelize)
require('./models/CollectionBook')(this.sequelize)
require('./models/Playlist')(this.sequelize)
require('./models/PlaylistMediaItem')(this.sequelize)
require('./models/Device')(this.sequelize)
require('./models/PlaybackSession')(this.sequelize)
require('./models/Feed')(this.sequelize)
require('./models/FeedEpisode')(this.sequelize)
require('./models/Setting')(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
/**
* Compare two server versions
* @param {string} v1
* @param {string} v2
* @returns {-1|0|1} 1 if v1 > v2
*/
compareVersions(v1, v2) {
if (!v1 || !v2) return 0
return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: "case", caseFirst: "upper" })
}
/**
* Checks if migration to sqlite db is necessary & runs migration.
*
* Check if version was upgraded and run any version specific migrations.
*
* Loads most of the data from the database. This is a temporary solution.
*/
async loadData() {
if (this.isNew && await dbMigration.checkShouldMigrate()) {
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
await dbMigration.migrate(this.models)
}
const startTime = Date.now()
const settingsData = await this.models.setting.getOldSettings()
this.settings = settingsData.settings
this.emailSettings = settingsData.emailSettings
this.serverSettings = settingsData.serverSettings
this.notificationSettings = settingsData.notificationSettings
global.ServerSettings = this.serverSettings.toJSON()
// Version specific migrations
if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) {
await dbMigration.migrationPatch(this)
}
if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) {
await dbMigration.migrationPatch2(this)
}
Logger.info(`[Database] Loading db data...`)
this.libraryItems = await this.models.libraryItem.loadAllLibraryItems()
Logger.info(`[Database] Loaded ${this.libraryItems.length} library items`)
this.authors = await this.models.author.getOldAuthors()
Logger.info(`[Database] Loaded ${this.authors.length} authors`)
this.series = await this.models.series.getAllOldSeries()
Logger.info(`[Database] Loaded ${this.series.length} series`)
// Set if root user has been created
this.hasRootUser = await this.models.user.getHasRootUser()
Logger.info(`[Database] Db data loaded in ${((Date.now() - startTime) / 1000).toFixed(2)}s`)
if (packageJson.version !== this.serverSettings.version) {
Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`)
this.serverSettings.version = packageJson.version
await this.updateServerSettings()
}
}
/**
* Create root user
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {boolean} true if created
*/
async createRootUser(username, pash, auth) {
if (!this.sequelize) return false
await this.models.user.createRootUser(username, pash, auth)
this.hasRootUser = true
return true
}
updateServerSettings() {
if (!this.sequelize) return false
global.ServerSettings = this.serverSettings.toJSON()
return this.updateSetting(this.serverSettings)
}
updateSetting(settings) {
if (!this.sequelize) return false
return this.models.setting.updateSettingObj(settings.toJSON())
}
async createUser(oldUser) {
if (!this.sequelize) return false
await this.models.user.createFromOld(oldUser)
return true
}
updateUser(oldUser) {
if (!this.sequelize) return false
return this.models.user.updateFromOld(oldUser)
}
updateBulkUsers(oldUsers) {
if (!this.sequelize) return false
return Promise.all(oldUsers.map(u => this.updateUser(u)))
}
async removeUser(userId) {
if (!this.sequelize) return false
await this.models.user.removeById(userId)
}
upsertMediaProgress(oldMediaProgress) {
if (!this.sequelize) return false
return this.models.mediaProgress.upsertFromOld(oldMediaProgress)
}
removeMediaProgress(mediaProgressId) {
if (!this.sequelize) return false
return this.models.mediaProgress.removeById(mediaProgressId)
}
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
}
async createLibrary(oldLibrary) {
if (!this.sequelize) return false
await this.models.library.createFromOld(oldLibrary)
}
updateLibrary(oldLibrary) {
if (!this.sequelize) return false
return this.models.library.updateFromOld(oldLibrary)
}
async removeLibrary(libraryId) {
if (!this.sequelize) return false
await this.models.library.removeById(libraryId)
}
async createCollection(oldCollection) {
if (!this.sequelize) return false
const newCollection = await this.models.collection.createFromOld(oldCollection)
// Create CollectionBooks
if (newCollection) {
const collectionBooks = []
oldCollection.books.forEach((libraryItemId) => {
const libraryItem = this.libraryItems.find(li => li.id === libraryItemId)
if (libraryItem) {
collectionBooks.push({
collectionId: newCollection.id,
bookId: libraryItem.media.id
})
}
})
if (collectionBooks.length) {
await this.createBulkCollectionBooks(collectionBooks)
}
}
}
updateCollection(oldCollection) {
if (!this.sequelize) return false
const collectionBooks = []
let order = 1
oldCollection.books.forEach((libraryItemId) => {
const libraryItem = this.getLibraryItem(libraryItemId)
if (!libraryItem) return
collectionBooks.push({
collectionId: oldCollection.id,
bookId: libraryItem.media.id,
order: order++
})
})
return this.models.collection.fullUpdateFromOld(oldCollection, collectionBooks)
}
async removeCollection(collectionId) {
if (!this.sequelize) return false
await this.models.collection.removeById(collectionId)
}
createCollectionBook(collectionBook) {
if (!this.sequelize) return false
return this.models.collectionBook.create(collectionBook)
}
createBulkCollectionBooks(collectionBooks) {
if (!this.sequelize) return false
return this.models.collectionBook.bulkCreate(collectionBooks)
}
removeCollectionBook(collectionId, bookId) {
if (!this.sequelize) return false
return this.models.collectionBook.removeByIds(collectionId, bookId)
}
async createPlaylist(oldPlaylist) {
if (!this.sequelize) return false
const newPlaylist = await this.models.playlist.createFromOld(oldPlaylist)
if (newPlaylist) {
const playlistMediaItems = []
let order = 1
for (const mediaItemObj of oldPlaylist.items) {
const libraryItem = this.libraryItems.find(li => li.id === mediaItemObj.libraryItemId)
if (!libraryItem) continue
let mediaItemId = libraryItem.media.id // bookId
let mediaItemType = 'book'
if (mediaItemObj.episodeId) {
mediaItemType = 'podcastEpisode'
mediaItemId = mediaItemObj.episodeId
}
playlistMediaItems.push({
playlistId: newPlaylist.id,
mediaItemId,
mediaItemType,
order: order++
})
}
if (playlistMediaItems.length) {
await this.createBulkPlaylistMediaItems(playlistMediaItems)
}
}
}
updatePlaylist(oldPlaylist) {
if (!this.sequelize) return false
const playlistMediaItems = []
let order = 1
oldPlaylist.items.forEach((item) => {
const libraryItem = this.getLibraryItem(item.libraryItemId)
if (!libraryItem) return
playlistMediaItems.push({
playlistId: oldPlaylist.id,
mediaItemId: item.episodeId || libraryItem.media.id,
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
})
return this.models.playlist.fullUpdateFromOld(oldPlaylist, playlistMediaItems)
}
async removePlaylist(playlistId) {
if (!this.sequelize) return false
await this.models.playlist.removeById(playlistId)
}
createPlaylistMediaItem(playlistMediaItem) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.create(playlistMediaItem)
}
createBulkPlaylistMediaItems(playlistMediaItems) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.bulkCreate(playlistMediaItems)
}
removePlaylistMediaItem(playlistId, mediaItemId) {
if (!this.sequelize) return false
return this.models.playlistMediaItem.removeByIds(playlistId, mediaItemId)
}
getLibraryItem(libraryItemId) {
if (!this.sequelize || !libraryItemId) return false
// Temp support for old library item ids from mobile
if (libraryItemId.startsWith('li_')) return this.libraryItems.find(li => li.oldLibraryItemId === libraryItemId)
return this.libraryItems.find(li => li.id === libraryItemId)
}
async createLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
this.libraryItems.push(oldLibraryItem)
}
async updateLibraryItem(oldLibraryItem) {
if (!this.sequelize) return false
await oldLibraryItem.saveMetadata()
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
}
async updateBulkLibraryItems(oldLibraryItems) {
if (!this.sequelize) return false
let updatesMade = 0
for (const oldLibraryItem of oldLibraryItems) {
await oldLibraryItem.saveMetadata()
const hasUpdates = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
if (hasUpdates) {
updatesMade++
}
}
return updatesMade
}
async createBulkLibraryItems(oldLibraryItems) {
if (!this.sequelize) return false
for (const oldLibraryItem of oldLibraryItems) {
await oldLibraryItem.saveMetadata()
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
this.libraryItems.push(oldLibraryItem)
}
}
async removeLibraryItem(libraryItemId) {
if (!this.sequelize) return false
await this.models.libraryItem.removeById(libraryItemId)
this.libraryItems = this.libraryItems.filter(li => li.id !== libraryItemId)
}
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
updateSeries(oldSeries) {
if (!this.sequelize) return false
return this.models.series.updateFromOld(oldSeries)
}
async createSeries(oldSeries) {
if (!this.sequelize) return false
await this.models.series.createFromOld(oldSeries)
this.series.push(oldSeries)
}
async createBulkSeries(oldSeriesObjs) {
if (!this.sequelize) return false
await this.models.series.createBulkFromOld(oldSeriesObjs)
this.series.push(...oldSeriesObjs)
}
async removeSeries(seriesId) {
if (!this.sequelize) return false
await this.models.series.removeById(seriesId)
this.series = this.series.filter(se => se.id !== seriesId)
}
async createAuthor(oldAuthor) {
if (!this.sequelize) return false
await this.models.author.createFromOld(oldAuthor)
this.authors.push(oldAuthor)
}
async createBulkAuthors(oldAuthors) {
if (!this.sequelize) return false
await this.models.author.createBulkFromOld(oldAuthors)
this.authors.push(...oldAuthors)
}
updateAuthor(oldAuthor) {
if (!this.sequelize) return false
return this.models.author.updateFromOld(oldAuthor)
}
async removeAuthor(authorId) {
if (!this.sequelize) return false
await this.models.author.removeById(authorId)
this.authors = this.authors.filter(au => au.id !== authorId)
}
async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors)
this.authors.push(...bookAuthors)
}
async removeBulkBookAuthors(authorId = null, bookId = null) {
if (!this.sequelize) return false
if (!authorId && !bookId) return
await this.models.bookAuthor.removeByIds(authorId, bookId)
this.authors = this.authors.filter(au => {
if (authorId && au.authorId !== authorId) return true
if (bookId && au.bookId !== bookId) return true
return false
})
}
getPlaybackSessions(where = null) {
if (!this.sequelize) return false
return this.models.playbackSession.getOldPlaybackSessions(where)
}
getPlaybackSession(sessionId) {
if (!this.sequelize) return false
return this.models.playbackSession.getById(sessionId)
}
createPlaybackSession(oldSession) {
if (!this.sequelize) return false
return this.models.playbackSession.createFromOld(oldSession)
}
updatePlaybackSession(oldSession) {
if (!this.sequelize) return false
return this.models.playbackSession.updateFromOld(oldSession)
}
removePlaybackSession(sessionId) {
if (!this.sequelize) return false
return this.models.playbackSession.removeById(sessionId)
}
getDeviceByDeviceId(deviceId) {
if (!this.sequelize) return false
return this.models.device.getOldDeviceByDeviceId(deviceId)
}
updateDevice(oldDevice) {
if (!this.sequelize) return false
return this.models.device.updateFromOld(oldDevice)
}
createDevice(oldDevice) {
if (!this.sequelize) return false
return this.models.device.createFromOld(oldDevice)
}
}
module.exports = new Database()

View file

@ -1,492 +0,0 @@
const Path = require('path')
const njodb = require('./libs/njodb')
const Logger = require('./Logger')
const { version } = require('../package.json')
const filePerms = require('./utils/filePerms')
const LibraryItem = require('./objects/LibraryItem')
const User = require('./objects/user/User')
const Collection = require('./objects/Collection')
const Playlist = require('./objects/Playlist')
const Library = require('./objects/Library')
const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series')
const ServerSettings = require('./objects/settings/ServerSettings')
const NotificationSettings = require('./objects/settings/NotificationSettings')
const PlaybackSession = require('./objects/PlaybackSession')
class Db {
constructor() {
this.LibraryItemsPath = Path.join(global.ConfigPath, 'libraryItems')
this.UsersPath = Path.join(global.ConfigPath, 'users')
this.SessionsPath = Path.join(global.ConfigPath, 'sessions')
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
this.PlaylistsPath = Path.join(global.ConfigPath, 'playlists')
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
this.SeriesPath = Path.join(global.ConfigPath, 'series')
this.FeedsPath = Path.join(global.ConfigPath, 'feeds')
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
this.libraryItems = []
this.users = []
this.libraries = []
this.settings = []
this.collections = []
this.playlists = []
this.authors = []
this.series = []
this.serverSettings = null
this.notificationSettings = null
// Stores previous version only if upgraded
this.previousVersion = null
}
get hasRootUser() {
return this.users.some(u => u.id === 'root')
}
getNjodbOptions() {
return {
lockoptions: {
stale: 1000 * 20, // 20 seconds
update: 2500,
retries: {
retries: 20,
minTimeout: 250,
maxTimeout: 5000,
factor: 1
}
}
}
}
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'session') return this.sessionsDb
else if (entityName === 'libraryItem') return this.libraryItemsDb
else if (entityName === 'library') return this.librariesDb
else if (entityName === 'settings') return this.settingsDb
else if (entityName === 'collection') return this.collectionsDb
else if (entityName === 'playlist') return this.playlistsDb
else if (entityName === 'author') return this.authorsDb
else if (entityName === 'series') return this.seriesDb
else if (entityName === 'feed') return this.feedsDb
return null
}
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
else if (entityName === 'session') return 'sessions'
else if (entityName === 'libraryItem') return 'libraryItems'
else if (entityName === 'library') return 'libraries'
else if (entityName === 'settings') return 'settings'
else if (entityName === 'collection') return 'collections'
else if (entityName === 'playlist') return 'playlists'
else if (entityName === 'author') return 'authors'
else if (entityName === 'series') return 'series'
else if (entityName === 'feed') return 'feeds'
return null
}
reinit() {
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath, this.getNjodbOptions())
this.usersDb = new njodb.Database(this.UsersPath, this.getNjodbOptions())
this.sessionsDb = new njodb.Database(this.SessionsPath, this.getNjodbOptions())
this.librariesDb = new njodb.Database(this.LibrariesPath, this.getNjodbOptions())
this.settingsDb = new njodb.Database(this.SettingsPath, this.getNjodbOptions())
this.collectionsDb = new njodb.Database(this.CollectionsPath, this.getNjodbOptions())
this.playlistsDb = new njodb.Database(this.PlaylistsPath, this.getNjodbOptions())
this.authorsDb = new njodb.Database(this.AuthorsPath, this.getNjodbOptions())
this.seriesDb = new njodb.Database(this.SeriesPath, this.getNjodbOptions())
this.feedsDb = new njodb.Database(this.FeedsPath, this.getNjodbOptions())
return this.init()
}
// Get previous server version before loading DB to check whether a db migration is required
// returns null if server was not upgraded
checkPreviousVersion() {
return this.settingsDb.select(() => true).then((results) => {
if (results.data && results.data.length) {
const serverSettings = results.data.find(s => s.id === 'server-settings')
if (serverSettings && serverSettings.version && serverSettings.version !== version) {
return serverSettings.version
}
}
return null
})
}
createRootUser(username, pash, token) {
const newRoot = new User({
id: 'root',
type: 'root',
username,
pash,
token,
isActive: true,
createdAt: Date.now()
})
return this.insertEntity('user', newRoot)
}
async init() {
await this.load()
// Set file ownership for all files created by db
await filePerms.setDefault(global.ConfigPath, true)
if (!this.serverSettings) { // Create first load server settings
this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings)
}
if (!this.notificationSettings) {
this.notificationSettings = new NotificationSettings()
await this.insertEntity('settings', this.notificationSettings)
}
global.ServerSettings = this.serverSettings.toJSON()
}
async load() {
const p1 = this.libraryItemsDb.select(() => true).then((results) => {
this.libraryItems = results.data.map(a => new LibraryItem(a))
Logger.info(`[DB] ${this.libraryItems.length} Library Items Loaded`)
})
const p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u))
Logger.info(`[DB] ${this.users.length} Users Loaded`)
})
const p3 = this.librariesDb.select(() => true).then((results) => {
this.libraries = results.data.map(l => new Library(l))
this.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
})
const p4 = this.settingsDb.select(() => true).then(async (results) => {
if (results.data && results.data.length) {
this.settings = results.data
const serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings)
// Check if server was upgraded
if (!this.serverSettings.version || this.serverSettings.version !== version) {
this.previousVersion = this.serverSettings.version || '1.0.0'
// Library settings and server settings updated in 2.1.3 - run migration
if (this.previousVersion.localeCompare('2.1.3') < 0) {
Logger.info(`[Db] Running servers & library settings migration`)
for (const library of this.libraries) {
if (library.settings.coverAspectRatio !== serverSettings.coverAspectRatio) {
library.settings.coverAspectRatio = serverSettings.coverAspectRatio
await this.updateEntity('library', library)
Logger.debug(`[Db] Library ${library.name} migrated`)
}
}
}
}
}
const notificationSettings = this.settings.find(s => s.id === 'notification-settings')
if (notificationSettings) {
this.notificationSettings = new NotificationSettings(notificationSettings)
}
}
})
const p5 = this.collectionsDb.select(() => true).then((results) => {
this.collections = results.data.map(l => new Collection(l))
Logger.info(`[DB] ${this.collections.length} Collections Loaded`)
})
const p6 = this.playlistsDb.select(() => true).then((results) => {
this.playlists = results.data.map(l => new Playlist(l))
Logger.info(`[DB] ${this.playlists.length} Playlists Loaded`)
})
const p7 = this.authorsDb.select(() => true).then((results) => {
this.authors = results.data.map(l => new Author(l))
Logger.info(`[DB] ${this.authors.length} Authors Loaded`)
})
const p8 = this.seriesDb.select(() => true).then((results) => {
this.series = results.data.map(l => new Series(l))
Logger.info(`[DB] ${this.series.length} Series Loaded`)
})
await Promise.all([p1, p2, p3, p4, p5, p6, p7, p8])
// Update server version in server settings
if (this.previousVersion) {
this.serverSettings.version = version
await this.updateServerSettings()
}
}
getLibraryItem(id) {
return this.libraryItems.find(li => li.id === id)
}
getLibraryItemsInLibrary(libraryId) {
return this.libraryItems.filter(li => li.libraryId === libraryId)
}
async updateLibraryItem(libraryItem) {
return this.updateLibraryItems([libraryItem])
}
async updateLibraryItems(libraryItems) {
await Promise.all(libraryItems.map(async (li) => {
if (li && li.saveMetadata) return li.saveMetadata()
return null
}))
const libraryItemIds = libraryItems.map(li => li.id)
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
return libraryItems.find(li => li.id === record.id)
}).then((results) => {
Logger.debug(`[DB] Library Items updated ${results.updated}`)
return true
}).catch((error) => {
Logger.error(`[DB] Library Items update failed ${error}`)
return false
})
}
async insertLibraryItem(libraryItem) {
return this.insertLibraryItems([libraryItem])
}
async insertLibraryItems(libraryItems) {
await Promise.all(libraryItems.map(async (li) => {
if (li && li.saveMetadata) return li.saveMetadata()
return null
}))
return this.libraryItemsDb.insert(libraryItems).then((results) => {
Logger.debug(`[DB] Library Items inserted ${results.inserted}`)
this.libraryItems = this.libraryItems.concat(libraryItems)
return true
}).catch((error) => {
Logger.error(`[DB] Library Items insert failed ${error}`)
return false
})
}
removeLibraryItem(id) {
return this.libraryItemsDb.delete((record) => record.id === id).then((results) => {
Logger.debug(`[DB] Deleted Library Items: ${results.deleted}`)
this.libraryItems = this.libraryItems.filter(li => li.id !== id)
}).catch((error) => {
Logger.error(`[DB] Remove Library Items Failed: ${error}`)
})
}
updateServerSettings() {
global.ServerSettings = this.serverSettings.toJSON()
return this.updateEntity('settings', this.serverSettings)
}
getAllEntities(entityName) {
const entityDb = this.getEntityDb(entityName)
return entityDb.select(() => true).then((results) => results.data).catch((error) => {
Logger.error(`[DB] Failed to get all ${entityName}`, error)
return null
})
}
insertEntities(entityName, entities) {
var entityDb = this.getEntityDb(entityName)
return entityDb.insert(entities).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) this[arrayKey] = this[arrayKey].concat(entities)
return true
}).catch((error) => {
Logger.error(`[DB] Failed to insert ${entityName}`, error)
return false
})
}
insertEntity(entityName, entity) {
var entityDb = this.getEntityDb(entityName)
return entityDb.insert([entity]).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) this[arrayKey].push(entity)
return true
}).catch((error) => {
Logger.error(`[DB] Failed to insert ${entityName}`, error)
return false
})
}
async bulkInsertEntities(entityName, entities, batchSize = 500) {
// Group entities in batches of size batchSize
var entityBatches = []
var batch = []
var index = 0
entities.forEach((ent) => {
batch.push(ent)
index++
if (index >= batchSize) {
entityBatches.push(batch)
index = 0
batch = []
}
})
if (batch.length) entityBatches.push(batch)
Logger.info(`[Db] bulkInsertEntities: ${entities.length} ${entityName} to ${entityBatches.length} batches of max size ${batchSize}`)
// Start inserting batches
var batchIndex = 1
for (const entityBatch of entityBatches) {
Logger.info(`[Db] bulkInsertEntities: Start inserting batch ${batchIndex} of ${entityBatch.length} for ${entityName}`)
var success = await this.insertEntities(entityName, entityBatch)
if (success) {
Logger.info(`[Db] bulkInsertEntities: Success inserting batch ${batchIndex} for ${entityName}`)
} else {
Logger.info(`[Db] bulkInsertEntities: Failed inserting batch ${batchIndex} for ${entityName}`)
}
batchIndex++
}
return true
}
updateEntities(entityName, entities) {
var entityDb = this.getEntityDb(entityName)
var entityIds = entities.map(ent => ent.id)
return entityDb.update((record) => entityIds.includes(record.id), (record) => {
return entities.find(ent => ent.id === record.id)
}).then((results) => {
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => {
if (entityIds.includes(e.id)) return entities.find(_e => _e.id === e.id)
return e
})
}
return true
}).catch((error) => {
Logger.error(`[DB] Update ${entityName} Failed: ${error}`)
return false
})
}
updateEntity(entityName, entity) {
const entityDb = this.getEntityDb(entityName)
let jsonEntity = entity
if (entity && entity.toJSON) {
jsonEntity = entity.toJSON()
}
return entityDb.update((record) => record.id === entity.id, () => jsonEntity).then((results) => {
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
const arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
})
}
return true
}).catch((error) => {
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
return false
})
}
removeEntity(entityName, entityId) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete((record) => {
return record.id === entityId
}).then((results) => {
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => {
return e.id !== entityId
})
}
}).catch((error) => {
Logger.error(`[DB] Remove entity ${entityName} Failed: ${error}`)
})
}
removeEntities(entityName, selectFunc, silent = false) {
var entityDb = this.getEntityDb(entityName)
return entityDb.delete(selectFunc).then((results) => {
if (!silent) Logger.debug(`[DB] Deleted entities ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => {
return !selectFunc(e)
})
}
return results.deleted
}).catch((error) => {
Logger.error(`[DB] Remove entities ${entityName} Failed: ${error}`)
return 0
})
}
recreateLibraryItemsDb() {
return this.libraryItemsDb.drop().then((results) => {
Logger.info(`[DB] Dropped library items db`, results)
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
this.libraryItems = []
return true
}).catch((error) => {
Logger.error(`[DB] Failed to drop library items db`, error)
return false
})
}
getAllSessions(selectFunc = () => true) {
return this.sessionsDb.select(selectFunc).then((results) => {
return results.data || []
}).catch((error) => {
Logger.error('[Db] Failed to select sessions', error)
return []
})
}
getPlaybackSession(id) {
return this.sessionsDb.select((pb) => pb.id == id).then((results) => {
if (results.data.length) {
return new PlaybackSession(results.data[0])
}
return null
}).catch((error) => {
Logger.error('Failed to get session', error)
return null
})
}
selectUserSessions(userId) {
return this.sessionsDb.select((session) => session.userId === userId).then((results) => {
return results.data || []
}).catch((error) => {
Logger.error(`[Db] Failed to select user sessions "${userId}"`, error)
return []
})
}
// Check if server was updated and previous version was earlier than param
checkPreviousVersionIsBefore(version) {
if (!this.previousVersion) return false
// true if version > previousVersion
return version.localeCompare(this.previousVersion) >= 0
}
}
module.exports = Db

View file

@ -3,7 +3,8 @@ const { LogLevel } = require('./utils/constants')
class Logger {
constructor() {
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
this.isDev = process.env.NODE_ENV !== 'production'
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
this.socketListeners = []
this.logManager = null
@ -86,6 +87,15 @@ class Logger {
this.debug(`Set Log Level to ${this.levelString}`)
}
/**
* Only to console and only for development
* @param {...any} args
*/
dev(...args) {
if (!this.isDev) return
console.log(`[${this.timestamp}] DEV:`, ...args)
}
trace(...args) {
if (this.logLevel > LogLevel.TRACE) return
console.trace(`[${this.timestamp}] TRACE:`, ...args)

View file

@ -1,4 +1,5 @@
const Path = require('path')
const Sequelize = require('sequelize')
const express = require('express')
const http = require('http')
const fs = require('./libs/fsExtra')
@ -8,7 +9,6 @@ const rateLimit = require('./libs/expressRateLimit')
const { version } = require('../package.json')
// Utils
const dbMigration = require('./utils/dbMigration')
const filePerms = require('./utils/filePerms')
const fileUtils = require('./utils/fileUtils')
const Logger = require('./Logger')
@ -16,14 +16,16 @@ const Logger = require('./Logger')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
const Scanner = require('./scanner/Scanner')
const Db = require('./Db')
const Database = require('./Database')
const SocketAuthority = require('./SocketAuthority')
const routes = require('./routes/index')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const StaticRouter = require('./routers/StaticRouter')
const NotificationManager = require('./managers/NotificationManager')
const EmailManager = require('./managers/EmailManager')
const CoverManager = require('./managers/CoverManager')
const AbMergeManager = require('./managers/AbMergeManager')
const CacheManager = require('./managers/CacheManager')
@ -63,30 +65,29 @@ class Server {
filePerms.setDefaultDirSync(global.MetadataPath, false)
}
this.db = new Db()
this.watcher = new Watcher()
this.auth = new Auth(this.db)
this.auth = new Auth()
// Managers
this.taskManager = new TaskManager()
this.notificationManager = new NotificationManager(this.db)
this.backupManager = new BackupManager(this.db)
this.logManager = new LogManager(this.db)
this.notificationManager = new NotificationManager()
this.emailManager = new EmailManager()
this.backupManager = new BackupManager()
this.logManager = new LogManager()
this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.db, this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager(this.db)
this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager)
this.rssFeedManager = new RssFeedManager(this.db)
this.abMergeManager = new AbMergeManager(this.taskManager)
this.playbackSessionManager = new PlaybackSessionManager()
this.coverManager = new CoverManager(this.cacheManager)
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
this.rssFeedManager = new RssFeedManager()
this.scanner = new Scanner(this.db, this.coverManager)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
this.scanner = new Scanner(this.coverManager, this.taskManager)
this.cronManager = new CronManager(this.scanner, this.podcastManager)
// Routers
this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager)
this.staticRouter = new StaticRouter(this.db)
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
Logger.logManager = this.logManager
@ -98,42 +99,37 @@ class Server {
this.auth.isAuthenticated(req, res, next)
}
/**
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
* Cleanup stale/invalid data
*/
async init() {
Logger.info('[Server] Init v' + version)
await this.playbackSessionManager.removeOrphanStreams()
const previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version
if (previousVersion) {
Logger.debug(`[Server] Upgraded from previous version ${previousVersion}`)
}
if (previousVersion && previousVersion.localeCompare('2.0.0') < 0) { // Old version data model migration
Logger.debug(`[Server] Previous version was < 2.0.0 - migration required`)
await dbMigration.migrate(this.db)
} else {
await this.db.init()
}
await Database.init(false)
// Create token secret if does not exist (Added v2.1.0)
if (!this.db.serverSettings.tokenSecret) {
if (!Database.serverSettings.tokenSecret) {
await this.auth.initTokenSecret()
}
await this.cleanUserData() // Remove invalid user item progress
await this.purgeMetadata() // Remove metadata folders without library item
await this.playbackSessionManager.removeInvalidSessions()
await this.cacheManager.ensureCachePaths()
await this.backupManager.init()
await this.logManager.init()
await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
await this.rssFeedManager.init()
this.cronManager.init()
if (this.db.serverSettings.scannerDisableWatcher) {
const libraries = await Database.models.library.getAllOldLibraries()
this.cronManager.init(libraries)
if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true
} else {
this.watcher.initWatcher(this.db.libraries)
this.watcher.initWatcher(libraries)
this.watcher.on('files', this.filesChanged.bind(this))
}
}
@ -169,49 +165,37 @@ class Server {
this.server = http.createServer(app)
router.use(fileUpload())
router.use(express.urlencoded({ extended: true, limit: "5mb" }))
router.use(this.auth.cors)
router.use(fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
}))
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
router.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
router.use(express.static(distPath))
// Metadata folder static path
router.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
// Static folder
router.use(express.static(Path.join(global.appRoot, 'static')))
// router.use('/api/v1', routes) // TODO: New routes
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
// Auth routes
this.auth.initAuthRoutes(router)
// EBook static file routes
router.get('/ebook/:library/:folder/*', (req, res) => {
const library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404)
const folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found')
const remainingPath = req.params['0']
const fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath)
})
// RSS Feed temp route
router.get('/feed/:id', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.id}`)
router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res)
})
router.get('/feed/:id/cover', (req, res) => {
router.get('/feed/:slug/cover', (req, res) => {
this.rssFeedManager.getFeedCover(req, res)
})
router.get('/feed/:id/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`)
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res)
})
@ -240,7 +224,7 @@ class Server {
// router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this))
// router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => {
if (this.db.hasRootUser) {
if (Database.hasRootUser) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500)
}
@ -250,8 +234,8 @@ class Server {
// status check for client to see if server has been initialized
// server has been initialized if a root user exists
const payload = {
isInit: this.db.hasRootUser,
language: this.db.serverSettings.language
isInit: Database.hasRootUser,
language: Database.serverSettings.language
}
if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath
@ -277,10 +261,10 @@ class Server {
async initializeServer(req, res) {
Logger.info(`[Server] Initializing new server`)
const newRoot = req.body.newRoot
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
let rootToken = await this.auth.generateAccessToken({ userId: 'root', username: newRoot.username })
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
await Database.createRootUser(rootUsername, rootPash, this.auth)
res.sendStatus(200)
}
@ -290,54 +274,49 @@ class Server {
await this.scanner.scanFilesChanged(fileUpdates)
}
// Remove unused /metadata/items/{id} folders
async purgeMetadata() {
const itemsMetadata = Path.join(global.MetadataPath, 'items')
if (!(await fs.pathExists(itemsMetadata))) return
const foldersInItemsMetadata = await fs.readdir(itemsMetadata)
let purged = 0
await Promise.all(foldersInItemsMetadata.map(async foldername => {
const hasMatchingItem = this.db.libraryItems.find(ab => ab.id === foldername)
if (!hasMatchingItem) {
const folderPath = Path.join(itemsMetadata, foldername)
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
await fs.remove(folderPath).then(() => {
purged++
}).catch((err) => {
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
})
}
}))
if (purged > 0) {
Logger.info(`[Server] Purged ${purged} unused library item metadata`)
}
return purged
}
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
/**
* Remove user media progress for items that no longer exist & remove seriesHideFrom that no longer exist
*/
async cleanUserData() {
for (let i = 0; i < this.db.users.length; i++) {
const _user = this.db.users[i]
let hasUpdated = false
if (_user.mediaProgress.length) {
const lengthBefore = _user.mediaProgress.length
_user.mediaProgress = _user.mediaProgress.filter(mp => {
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
if (!libraryItem) return false
if (mp.episodeId && (libraryItem.mediaType !== 'podcast' || !libraryItem.media.checkHasEpisode(mp.episodeId))) return false // Episode not found
return true
})
if (lengthBefore > _user.mediaProgress.length) {
Logger.debug(`[Server] Removing ${_user.mediaProgress.length - lengthBefore} media progress data from user ${_user.username}`)
hasUpdated = true
// Get all media progress without an associated media item
const mediaProgressToRemove = await Database.models.mediaProgress.findAll({
where: {
'$podcastEpisode.id$': null,
'$book.id$': null
},
attributes: ['id'],
include: [
{
model: Database.models.book,
attributes: ['id']
},
{
model: Database.models.podcastEpisode,
attributes: ['id']
}
]
})
if (mediaProgressToRemove.length) {
// Remove media progress
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
where: {
id: {
[Sequelize.Op.in]: mediaProgressToRemove.map(mp => mp.id)
}
}
})
if (mediaProgressRemoved) {
Logger.info(`[Server] Removed ${mediaProgressRemoved} media progress for media items that no longer exist in db`)
}
}
// Remove series from hide from continue listening that no longer exist
const users = await Database.models.user.getOldUsers()
for (const _user of users) {
let hasUpdated = false
if (_user.seriesHideFromContinueListening.length) {
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
if (!this.db.series.some(se => se.id === seriesId)) { // Series removed
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
hasUpdated = true
return false
}
@ -345,7 +324,7 @@ class Server {
})
}
if (hasUpdated) {
await this.db.updateEntity('user', _user)
await Database.updateUser(_user)
}
}
}
@ -358,8 +337,8 @@ class Server {
getLoginRateLimiter() {
return rateLimit({
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
max: this.db.serverSettings.rateLimitLoginRequests,
windowMs: Database.serverSettings.rateLimitLoginWindow, // 5 minutes
max: Database.serverSettings.rateLimitLoginRequests,
skipSuccessfulRequests: true,
onLimitReached: this.loginLimitReached
})

View file

@ -1,5 +1,6 @@
const SocketIO = require('socket.io')
const Logger = require('./Logger')
const Database = require('./Database')
class SocketAuthority {
constructor() {
@ -18,7 +19,7 @@ class SocketAuthority {
onlineUsersMap[client.user.id].connections++
} else {
onlineUsersMap[client.user.id] = {
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems),
...client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems),
connections: 1
}
}
@ -47,7 +48,7 @@ class SocketAuthority {
clientEmitter(userId, evt, data) {
const clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
@ -82,7 +83,7 @@ class SocketAuthority {
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[Server] Socket Connected', socket.id)
Logger.info('[SocketAuthority] Socket Connected', socket.id)
// Required for associating a User with a socket
socket.on('auth', (token) => this.authenticateSocket(socket, token))
@ -101,16 +102,16 @@ class SocketAuthority {
const _client = this.clients[socket.id]
if (!_client) {
Logger.warn(`[Server] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`)
} else if (!_client.user) {
Logger.info(`[Server] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`)
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
Logger.debug('[SocketAuthority] User Offline ' + _client.user.username)
this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`)
delete this.clients[socket.id]
}
})
@ -125,13 +126,13 @@ class SocketAuthority {
if (client.user && client.user.isAdminOrUp) {
this.emitter('admin_message', payload.message || '')
} else {
Logger.error(`[Server] Non-admin user sent the message_all_users event`)
Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`)
}
})
socket.on('ping', () => {
const client = this.clients[socket.id] || {}
const user = client.user || {}
Logger.debug(`[Server] Received ping from socket ${user.username || 'No User'}`)
Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`)
socket.emit('pong')
})
})
@ -146,9 +147,13 @@ class SocketAuthority {
return socket.emit('invalid_token')
}
const client = this.clients[socket.id]
if (!client) {
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
return
}
if (client.user !== undefined) {
Logger.debug(`[Server] Authenticating socket client already has user`, client.user.username)
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
}
client.user = user
@ -158,13 +163,13 @@ class SocketAuthority {
return
}
Logger.debug(`[Server] User Online ${client.user.username}`)
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, this.Server.db.libraryItems))
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions, Database.libraryItems))
// Update user lastSeen
user.lastSeen = Date.now()
await this.Server.db.updateEntity('user', user)
await Database.updateUser(user)
const initialPayload = {
userId: client.user.id,
@ -182,22 +187,22 @@ class SocketAuthority {
if (socketId && this.clients[socketId]) {
const client = this.clients[socketId]
const clientSocket = client.socket
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
Logger.debug(`[SocketAuthority] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, this.Server.db.libraryItems))
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
this.adminEmitter('user_offline', client.user.toJSONForPublic(null, Database.libraryItems))
}
delete this.clients[socketId].user
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
Logger.warn(`[SocketAuthority] No client for socket ${socketId}`)
}
}
cancelScan(id) {
Logger.debug('[Server] Cancel scan', id)
Logger.debug('[SocketAuthority] Cancel scan', id)
this.Server.scanner.setCancelLibraryScan(id)
}
}

View file

@ -4,6 +4,7 @@ const { createNewSortInstance } = require('../libs/fastSort')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { reqSupportsWebp } = require('../utils/index')
@ -14,18 +15,13 @@ class AuthorController {
constructor() { }
async findOne(req, res) {
const libraryId = req.query.library
const include = (req.query.include || '').split(',')
const authorJson = req.author.toJSON()
// Used on author landing page to include library items and items grouped in series
if (include.includes('items')) {
authorJson.libraryItems = this.db.libraryItems.filter(li => {
if (libraryId && li.libraryId !== libraryId) return false
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
})
authorJson.libraryItems = await Database.models.libraryItem.getForAuthor(req.author, req.user)
if (include.includes('series')) {
const seriesMap = {}
@ -97,25 +93,29 @@ class AuthorController {
const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
// Check if author name matches another author and merge the authors
const existingAuthor = authorNameUpdate ? this.db.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
const existingAuthor = authorNameUpdate ? Database.authors.find(au => au.id !== req.author.id && payload.name === au.name) : false
if (existingAuthor) {
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
const bookAuthorsToCreate = []
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
itemsWithAuthor.forEach(libraryItem => { // Replace old author with merging author for each book
libraryItem.media.metadata.replaceAuthor(req.author, existingAuthor)
bookAuthorsToCreate.push({
bookId: libraryItem.media.id,
authorId: existingAuthor.id
})
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor
await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
// Remove old author
await this.db.removeEntity('author', req.author.id)
await Database.removeAuthor(req.author.id)
SocketAuthority.emitter('author_removed', req.author.toJSON())
// Send updated num books for merged author
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(existingAuthor.id)
}).length
const numBooks = await Database.models.libraryItem.getForAuthor(existingAuthor).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))
res.json({
@ -130,22 +130,18 @@ class AuthorController {
if (hasUpdated) {
req.author.updatedAt = Date.now()
const itemsWithAuthor = await Database.models.libraryItem.getForAuthor(req.author)
if (authorNameUpdate) { // Update author name on all books
const itemsWithAuthor = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasAuthor(req.author.id))
itemsWithAuthor.forEach(libraryItem => {
libraryItem.media.metadata.updateAuthor(req.author)
})
if (itemsWithAuthor.length) {
await this.db.updateLibraryItems(itemsWithAuthor)
SocketAuthority.emitter('items_updated', itemsWithAuthor.map(li => li.toJSONExpanded()))
}
}
await this.db.updateEntity('author', req.author)
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
await Database.updateAuthor(req.author)
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(itemsWithAuthor.length))
}
res.json({
@ -159,7 +155,7 @@ class AuthorController {
var q = (req.query.q || '').toLowerCase()
if (!q) return res.json([])
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var authors = this.db.authors.filter(au => au.name.toLowerCase().includes(q))
var authors = Database.authors.filter(au => au.name?.toLowerCase().includes(q))
authors = authors.slice(0, limit)
res.json({
results: authors
@ -204,10 +200,9 @@ class AuthorController {
if (hasUpdates) {
req.author.updatedAt = Date.now()
await this.db.updateEntity('author', req.author)
const numBooks = this.db.libraryItems.filter(li => {
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
}).length
await Database.updateAuthor(req.author)
const numBooks = await Database.models.libraryItem.getForAuthor(req.author).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}
@ -238,7 +233,7 @@ class AuthorController {
}
middleware(req, res, next) {
var author = this.db.authors.find(au => au.id === req.params.id)
const author = Database.authors.find(au => au.id === req.params.id)
if (!author) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {

View file

@ -14,18 +14,14 @@ class BackupController {
}
async delete(req, res) {
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) {
return res.sendStatus(404)
}
await this.backupManager.removeBackup(backup)
await this.backupManager.removeBackup(req.backup)
res.json({
backups: this.backupManager.backups.map(b => b.toJSON())
})
}
async upload(req, res) {
upload(req, res) {
if (!req.files.file) {
Logger.error('[BackupController] Upload backup invalid')
return res.sendStatus(500)
@ -33,13 +29,22 @@ class BackupController {
this.backupManager.uploadBackup(req, res)
}
async apply(req, res) {
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!backup) {
return res.sendStatus(404)
/**
* api/backups/:id/download
*
* @param {*} req
* @param {*} res
*/
download(req, res) {
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${req.backup.fullPath}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + req.backup.fullPath }).send()
}
await this.backupManager.requestApplyBackup(backup)
res.sendStatus(200)
res.sendFile(req.backup.fullPath)
}
apply(req, res) {
this.backupManager.requestApplyBackup(req.backup, res)
}
middleware(req, res, next) {
@ -47,6 +52,14 @@ class BackupController {
Logger.error(`[BackupController] Non-admin user attempting to access backups`, req.user)
return res.sendStatus(403)
}
if (req.params.id) {
req.backup = this.backupManager.backups.find(b => b.id === req.params.id)
if (!req.backup) {
return res.sendStatus(404)
}
}
next()
}
}

View file

@ -8,7 +8,6 @@ class CacheController {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging all cache`)
await this.cacheManager.purgeAll()
res.sendStatus(200)
}
@ -18,7 +17,6 @@ class CacheController {
if (!req.user.isAdminOrUp) {
return res.sendStatus(403)
}
Logger.info(`[MiscController] Purging items cache`)
await this.cacheManager.purgeItems()
res.sendStatus(200)
}

View file

@ -1,5 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Collection = require('../objects/Collection')
@ -7,32 +8,34 @@ class CollectionController {
constructor() { }
async create(req, res) {
var newCollection = new Collection()
const newCollection = new Collection()
req.body.userId = req.user.id
var success = newCollection.setData(req.body)
if (!success) {
if (!newCollection.setData(req.body)) {
return res.status(500).send('Invalid collection data')
}
var jsonExpanded = newCollection.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('collection', newCollection)
const libraryItemsInCollection = await Database.models.libraryItem.getForCollection(newCollection)
const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection)
await Database.createCollection(newCollection)
SocketAuthority.emitter('collection_added', jsonExpanded)
res.json(jsonExpanded)
}
findAll(req, res) {
async findAll(req, res) {
const collectionsExpanded = await Database.models.collection.getOldCollectionsJsonExpanded(req.user)
res.json({
collections: this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
collections: collectionsExpanded
})
}
findOne(req, res) {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
const collectionExpanded = req.collection.toJSONExpanded(this.db.libraryItems)
const collectionExpanded = req.collection.toJSONExpanded(Database.libraryItems)
if (includeEntities.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData ? feedData.toJSONMinified() : null
const feedData = await this.rssFeedManager.findFeedForEntityId(collectionExpanded.id)
collectionExpanded.rssFeed = feedData?.toJSONMinified() || null
}
res.json(collectionExpanded)
@ -41,9 +44,9 @@ class CollectionController {
async update(req, res) {
const collection = req.collection
const wasUpdated = collection.update(req.body)
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
if (wasUpdated) {
await this.db.updateEntity('collection', collection)
await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', jsonExpanded)
}
res.json(jsonExpanded)
@ -51,19 +54,19 @@ class CollectionController {
async delete(req, res) {
const collection = req.collection
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
// Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(collection.id)
await this.db.removeEntity('collection', collection.id)
await Database.removeCollection(collection.id)
SocketAuthority.emitter('collection_removed', jsonExpanded)
res.sendStatus(200)
}
async addBook(req, res) {
const collection = req.collection
const libraryItem = this.db.libraryItems.find(li => li.id === req.body.id)
const libraryItem = Database.libraryItems.find(li => li.id === req.body.id)
if (!libraryItem) {
return res.status(500).send('Book not found')
}
@ -74,8 +77,14 @@ class CollectionController {
return res.status(500).send('Book already in collection')
}
collection.addBook(req.body.id)
const jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
const collectionBook = {
collectionId: collection.id,
bookId: libraryItem.media.id,
order: collection.books.length
}
await Database.createCollectionBook(collectionBook)
SocketAuthority.emitter('collection_updated', jsonExpanded)
res.json(jsonExpanded)
}
@ -83,13 +92,18 @@ class CollectionController {
// DELETE: api/collections/:id/book/:bookId
async removeBook(req, res) {
const collection = req.collection
const libraryItem = Database.libraryItems.find(li => li.id === req.params.bookId)
if (!libraryItem) {
return res.sendStatus(404)
}
if (collection.books.includes(req.params.bookId)) {
collection.removeBook(req.params.bookId)
var jsonExpanded = collection.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('collection', collection)
const jsonExpanded = collection.toJSONExpanded(Database.libraryItems)
SocketAuthority.emitter('collection_updated', jsonExpanded)
await Database.updateCollection(collection)
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
res.json(collection.toJSONExpanded(Database.libraryItems))
}
// POST: api/collections/:id/batch/add
@ -98,19 +112,30 @@ class CollectionController {
if (!req.body.books || !req.body.books.length) {
return res.status(500).send('Invalid request body')
}
var bookIdsToAdd = req.body.books
var hasUpdated = false
for (let i = 0; i < bookIdsToAdd.length; i++) {
if (!collection.books.includes(bookIdsToAdd[i])) {
collection.addBook(bookIdsToAdd[i])
const bookIdsToAdd = req.body.books
const collectionBooksToAdd = []
let hasUpdated = false
let order = collection.books.length
for (const libraryItemId of bookIdsToAdd) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) continue
if (!collection.books.includes(libraryItemId)) {
collection.addBook(libraryItemId)
collectionBooksToAdd.push({
collectionId: collection.id,
bookId: libraryItem.media.id,
order: order++
})
hasUpdated = true
}
}
if (hasUpdated) {
await this.db.updateEntity('collection', collection)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
await Database.createBulkCollectionBooks(collectionBooksToAdd)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
res.json(collection.toJSONExpanded(Database.libraryItems))
}
// POST: api/collections/:id/batch/remove
@ -120,23 +145,26 @@ class CollectionController {
return res.status(500).send('Invalid request body')
}
var bookIdsToRemove = req.body.books
var hasUpdated = false
for (let i = 0; i < bookIdsToRemove.length; i++) {
if (collection.books.includes(bookIdsToRemove[i])) {
collection.removeBook(bookIdsToRemove[i])
let hasUpdated = false
for (const libraryItemId of bookIdsToRemove) {
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) continue
if (collection.books.includes(libraryItemId)) {
collection.removeBook(libraryItemId)
hasUpdated = true
}
}
if (hasUpdated) {
await this.db.updateEntity('collection', collection)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems))
await Database.updateCollection(collection)
SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(Database.libraryItems))
}
res.json(collection.toJSONExpanded(this.db.libraryItems))
res.json(collection.toJSONExpanded(Database.libraryItems))
}
middleware(req, res, next) {
async middleware(req, res, next) {
if (req.params.id) {
const collection = this.db.collections.find(c => c.id === req.params.id)
const collection = await Database.models.collection.getById(req.params.id)
if (!collection) {
return res.status(404).send('Collection not found')
}

View file

@ -0,0 +1,87 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
class EmailController {
constructor() { }
getSettings(req, res) {
res.json({
settings: Database.emailSettings
})
}
async updateSettings(req, res) {
const updated = Database.emailSettings.update(req.body)
if (updated) {
await Database.updateSetting(Database.emailSettings)
}
res.json({
settings: Database.emailSettings
})
}
async sendTest(req, res) {
this.emailManager.sendTest(res)
}
async updateEReaderDevices(req, res) {
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
return res.status(400).send('Invalid payload. ereaderDevices array required')
}
const ereaderDevices = req.body.ereaderDevices
for (const device of ereaderDevices) {
if (!device.name || !device.email) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
}
}
const updated = Database.emailSettings.update({
ereaderDevices
})
if (updated) {
await Database.updateSetting(Database.emailSettings)
SocketAuthority.adminEmitter('ereader-devices-updated', {
ereaderDevices: Database.emailSettings.ereaderDevices
})
}
res.json({
ereaderDevices: Database.emailSettings.ereaderDevices
})
}
async sendEBookToDevice(req, res) {
Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`)
const libraryItem = Database.getLibraryItem(req.body.libraryItemId)
if (!libraryItem) {
return res.status(404).send('Library item not found')
}
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403)
}
const ebookFile = libraryItem.media.ebookFile
if (!ebookFile) {
return res.status(404).send('EBook file not found')
}
const device = Database.emailSettings.getEReaderDevice(req.body.deviceName)
if (!device) {
return res.status(404).send('E-reader device not found')
}
this.emailManager.sendEBookToDevice(ebookFile, device, res)
}
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
return res.sendStatus(404)
}
next()
}
}
module.exports = new EmailController()

View file

@ -1,27 +1,50 @@
const Logger = require('../Logger')
const Path = require('path')
const Logger = require('../Logger')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
class FileSystemController {
constructor() { }
async getPaths(req, res) {
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
if (!req.user.isAdminOrUp) {
Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user)
return res.sendStatus(403)
}
const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => {
return Path.sep + dirname
})
// Do not include existing mapped library paths in response
this.db.libraries.forEach(lib => {
lib.folders.forEach((folder) => {
var dir = folder.fullPath
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
excludedDirs.push(dir)
})
const libraryFoldersPaths = await Database.models.libraryFolder.getAllLibraryFolderPaths()
libraryFoldersPaths.forEach((path) => {
let dir = path || ''
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
excludedDirs.push(dir)
})
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
res.json({
directories: await this.getDirectories(global.appRoot, '/', excludedDirs)
})
}
// POST: api/filesystem/pathexists
async checkPathExists(req, res) {
if (!req.user.canUpload) {
Logger.error(`[FileSystemController] Non-admin user attempting to check path exists`, req.user)
return res.sendStatus(403)
}
const filepath = req.body.filepath
if (!filepath?.length) {
return res.sendStatus(400)
}
const exists = await fs.pathExists(filepath)
res.json({
exists
})
}
}
module.exports = new FileSystemController()

View file

@ -9,6 +9,9 @@ const { sort, createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
})
const Database = require('../Database')
class LibraryController {
constructor() { }
@ -40,13 +43,16 @@ class LibraryController {
}
const library = new Library()
newLibraryPayload.displayOrder = this.db.libraries.length + 1
let currentLargestDisplayOrder = await Database.models.library.getMaxDisplayOrder()
if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0
newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1
library.setData(newLibraryPayload)
await this.db.insertEntity('library', library)
await Database.createLibrary(library)
// Only emit to users with access to library
const userFilter = (user) => {
return user.checkCanAccessLibrary && user.checkCanAccessLibrary(library.id)
return user.checkCanAccessLibrary?.(library.id)
}
SocketAuthority.emitter('library_added', library.toJSON(), userFilter)
@ -56,16 +62,18 @@ class LibraryController {
res.json(library)
}
findAll(req, res) {
async findAll(req, res) {
const libraries = await Database.models.library.getAllOldLibraries()
const librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length) {
if (librariesAccessible.length) {
return res.json({
libraries: this.db.libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
libraries: libraries.filter(lib => librariesAccessible.includes(lib.id)).map(lib => lib.toJSON())
})
}
res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
libraries: libraries.map(lib => lib.toJSON())
})
}
@ -75,7 +83,7 @@ class LibraryController {
return res.json({
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
issues: req.libraryItems.filter(li => li.hasIssues).length,
numUserPlaylists: this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).length,
numUserPlaylists: await Database.models.playlist.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
library: req.library
})
}
@ -128,14 +136,14 @@ class LibraryController {
this.cronManager.updateLibraryScanCron(library)
// Remove libraryItems no longer in library
const itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
const itemsToRemove = Database.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path))
if (itemsToRemove.length) {
Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`)
for (let i = 0; i < itemsToRemove.length; i++) {
await this.handleDeleteLibraryItem(itemsToRemove[i])
}
}
await this.db.updateEntity('library', library)
await Database.updateLibrary(library)
// Only emit to users with access to library
const userFilter = (user) => {
@ -146,6 +154,12 @@ class LibraryController {
return res.json(library.toJSON())
}
/**
* DELETE: /api/libraries/:id
* Delete a library
* @param {*} req
* @param {*} res
*/
async delete(req, res) {
const library = req.library
@ -153,28 +167,56 @@ class LibraryController {
this.watcher.removeLibrary(library)
// Remove collections for library
const collections = this.db.collections.filter(c => c.libraryId === library.id)
for (const collection of collections) {
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
await this.db.removeEntity('collection', collection.id)
const numCollectionsRemoved = await Database.models.collection.removeAllForLibrary(library.id)
if (numCollectionsRemoved) {
Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${library.name}"`)
}
// Remove items in this library
const libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
const libraryItems = Database.libraryItems.filter(li => li.libraryId === library.id)
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
for (let i = 0; i < libraryItems.length; i++) {
await this.handleDeleteLibraryItem(libraryItems[i])
}
const libraryJson = library.toJSON()
await this.db.removeEntity('library', library.id)
await Database.removeLibrary(library.id)
// Re-order libraries
await Database.models.library.resetDisplayOrder()
SocketAuthority.emitter('library_removed', libraryJson)
return res.json(libraryJson)
}
async getLibraryItemsNew(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: undefined,
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,
mediaType: req.library.mediaType,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1',
include: include.join(',')
}
payload.offset = payload.page * payload.limit
const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library, req.user, payload)
payload.results = libraryItems
payload.total = count
res.json(payload)
}
// api/libraries/:id/items
// TODO: Optimize this method, items are iterated through several times but can be combined
getLibraryItems(req, res) {
async getLibraryItems(req, res) {
let libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
@ -193,11 +235,12 @@ class LibraryController {
include: include.join(',')
}
const mediaIsBook = payload.mediaType === 'book'
const mediaIsPodcast = payload.mediaType === 'podcast'
// Step 1 - Filter the retrieved library items
let filterSeries = null
if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
libraryItems = await libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
payload.total = libraryItems.length
// Determining if we are filtering titles by a series, and if so, which series
@ -209,7 +252,7 @@ class LibraryController {
// If also filtering by series, will not collapse the filtered series as this would lead
// to series having a collapsed series that is just that series.
if (payload.collapseseries) {
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries)
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries)
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
libraryItems = collapsedItems
@ -231,13 +274,12 @@ class LibraryController {
const sortArray = []
// When on the series page, sort by sequence only
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
if (filterSeries && !payload.sortBy) {
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
// If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
sortArray.push({
asc: (li) => {
if (this.db.serverSettings.sortingIgnorePrefix) {
if (Database.serverSettings.sortingIgnorePrefix) {
return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
} else {
return li.collapsedSeries?.name || li.media.metadata.title
@ -247,15 +289,11 @@ class LibraryController {
}
if (payload.sortBy) {
// old sort key TODO: should be mutated in dbMigration
let sortKey = payload.sortBy
if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.')
}
// Handle server setting sortingIgnorePrefix
const sortByTitle = sortKey === 'media.metadata.title'
if (sortByTitle && this.db.serverSettings.sortingIgnorePrefix) {
if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix'
}
@ -267,7 +305,7 @@ class LibraryController {
sortArray.push({
asc: (li) => {
if (li.collapsedSeries) {
return this.db.serverSettings.sortingIgnorePrefix ?
return Database.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
@ -284,7 +322,7 @@ class LibraryController {
if (mediaIsBook && sortBySequence) {
return li.media.metadata.getSeries(filterSeries).sequence
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
return this.db.serverSettings.sortingIgnorePrefix ?
return Database.serverSettings.sortingIgnorePrefix ?
li.collapsedSeries.nameIgnorePrefix :
li.collapsedSeries.name
} else {
@ -318,7 +356,7 @@ class LibraryController {
}
// Step 4 - Transform the items to pass to the client side
payload.results = libraryItems.map(li => {
payload.results = await Promise.all(libraryItems.map(async li => {
const json = payload.minified ? li.toJSONMinified() : li.toJSON()
if (li.collapsedSeries) {
@ -355,10 +393,15 @@ class LibraryController {
} else {
// add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(json.id)
const feedData = await this.rssFeedManager.findFeedForEntityId(json.id)
json.rssFeed = feedData ? feedData.toJSONMinified() : null
}
// add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
if (mediaIsPodcast && include.includes('numepisodesincomplete')) {
json.numEpisodesIncomplete = req.user.getNumEpisodesIncompleteForPodcast(li)
}
if (filterSeries) {
// If filtering by series, make sure to include the series metadata
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
@ -366,7 +409,7 @@ class LibraryController {
}
return json
})
}))
res.json(payload)
}
@ -387,7 +430,13 @@ class LibraryController {
res.sendStatus(200)
}
// api/libraries/:id/series
/**
* api/libraries/:id/series
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
* @param {*} req
* @param {*} res
*/
async getAllSeriesForLibrary(req, res) {
const libraryItems = req.libraryItems
@ -405,7 +454,7 @@ class LibraryController {
include: include.join(',')
}
let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries)
const direction = payload.sortDesc ? 'desc' : 'asc'
series = naturalSort(series).by([
@ -422,7 +471,7 @@ class LibraryController {
} else if (payload.sortBy === 'lastBookAdded') {
return Math.max(...(se.books).map(x => x.addedAt), 0)
} else { // sort by name
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
}
}
}
@ -437,21 +486,55 @@ class LibraryController {
// add rssFeed when "include=rssfeed" is in query string
if (include.includes('rssfeed')) {
series = series.map((se) => {
const feedData = this.rssFeedManager.findFeedForEntityId(se.id)
series = await Promise.all(series.map(async (se) => {
const feedData = await this.rssFeedManager.findFeedForEntityId(se.id)
se.rssFeed = feedData?.toJSONMinified() || null
return se
})
}))
}
payload.results = series
res.json(payload)
}
/**
* api/libraries/:id/series/:seriesId
*
* Optional includes (e.g. `?include=rssfeed,progress`)
* rssfeed: adds `rssFeed` to series object if a feed is open
* progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }
*
* @param {*} req
* @param {*} res - Series
*/
async getSeriesForLibrary(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const series = Database.series.find(se => se.id === req.params.seriesId)
if (!series) return res.sendStatus(404)
const libraryItemsInSeries = req.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
const seriesJson = series.toJSON()
if (include.includes('progress')) {
const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished)
seriesJson.progress = {
libraryItemIds: libraryItemsInSeries.map(li => li.id),
libraryItemIdsFinished: libraryItemsFinished.map(li => li.id),
isFinished: libraryItemsFinished.length >= libraryItemsInSeries.length
}
}
if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
}
res.json(seriesJson)
}
// api/libraries/:id/collections
async getCollectionsForLibrary(req, res) {
const libraryItems = req.libraryItems
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
@ -466,19 +549,8 @@ class LibraryController {
include: include.join(',')
}
let collections = this.db.collections.filter(c => c.libraryId === req.library.id).map(c => {
const expanded = c.toJSONExpanded(libraryItems, payload.minified)
// If all books restricted to user in this collection then hide this collection
if (!expanded.books.length && c.books.length) return null
if (include.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(c.id)
expanded.rssFeed = feedData?.toJSONMinified() || null
}
return expanded
}).filter(c => !!c)
// TODO: Create paginated queries
let collections = await Database.models.collection.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
payload.total = collections.length
@ -493,7 +565,8 @@ class LibraryController {
// api/libraries/:id/playlists
async getUserPlaylistsForLibrary(req, res) {
let playlistsForUser = this.db.playlists.filter(p => p.userId === req.user.id && p.libraryId === req.library.id).map(p => p.toJSONExpanded(this.db.libraryItems))
let playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id, req.library.id)
playlistsForUser = playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
const payload = {
results: [],
@ -517,7 +590,7 @@ class LibraryController {
return res.status(400).send('Invalid library media type')
}
let libraryItems = this.db.libraryItems.filter(li => li.libraryId === req.library.id)
let libraryItems = Database.libraryItems.filter(li => li.libraryId === req.library.id)
let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems)
albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
@ -541,48 +614,68 @@ class LibraryController {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
}
// api/libraries/:id/personalized
// New and improved personalized call only loops through library items once
/**
* GET: /api/libraries/:id/personalized2
* TODO: new endpoint
* @param {*} req
* @param {*} res
*/
async getUserPersonalizedShelves(req, res) {
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
res.json(shelves)
}
/**
* GET: /api/libraries/:id/personalized
* @param {*} req
* @param {*} res
*/
async getLibraryUserPersonalizedOptimal(req, res) {
const mediaType = req.library.mediaType
const libraryItems = req.libraryItems
const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const categories = libraryHelpers.buildPersonalizedShelves(this, req.user, libraryItems, mediaType, limitPerShelf, include)
const categories = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include)
res.json(categories)
}
// PATCH: Change the order of libraries
/**
* POST: /api/libraries/order
* Change the display order of libraries
* @param {*} req
* @param {*} res
*/
async reorder(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
return res.sendStatus(403)
}
const libraries = await Database.models.library.getAllOldLibraries()
var orderdata = req.body
var hasUpdates = false
const orderdata = req.body
let hasUpdates = false
for (let i = 0; i < orderdata.length; i++) {
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
const library = libraries.find(lib => lib.id === orderdata[i].id)
if (!library) {
Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)
return res.sendStatus(500)
}
if (library.update({ displayOrder: orderdata[i].newOrder })) {
hasUpdates = true
await this.db.updateEntity('library', library)
await Database.updateLibrary(library)
}
}
if (hasUpdates) {
this.db.libraries.sort((a, b) => a.displayOrder - b.displayOrder)
libraries.sort((a, b) => a.displayOrder - b.displayOrder)
Logger.debug(`[LibraryController] Updated library display orders`)
} else {
Logger.debug(`[LibraryController] Library orders were up to date`)
}
res.json({
libraries: this.db.libraries.map(lib => lib.toJSON())
libraries: libraries.map(lib => lib.toJSON())
})
}
@ -612,7 +705,7 @@ class LibraryController {
if (queryResult.series?.length) {
queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) {
const _series = this.db.series.find(_se => _se.id === se.id)
const _series = Database.series.find(_se => _se.id === se.id)
if (_series) seriesMatches[se.id] = { series: _series.toJSON(), books: [li.toJSON()] }
} else {
seriesMatches[se.id].books.push(li.toJSON())
@ -622,7 +715,7 @@ class LibraryController {
if (queryResult.authors?.length) {
queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) {
const _author = this.db.authors.find(_au => _au.id === au.id)
const _author = Database.authors.find(_au => _au.id === au.id)
if (_author) {
authorMatches[au.id] = _author.toJSON()
authorMatches[au.id].numBooks = 1
@ -689,7 +782,7 @@ class LibraryController {
if (li.media.metadata.authors && li.media.metadata.authors.length) {
li.media.metadata.authors.forEach((au) => {
if (!authors[au.id]) {
const _author = this.db.authors.find(_au => _au.id === au.id)
const _author = Database.authors.find(_au => _au.id === au.id)
if (_author) {
authors[au.id] = _author.toJSON()
authors[au.id].numBooks = 1
@ -751,7 +844,7 @@ class LibraryController {
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
@ -776,7 +869,7 @@ class LibraryController {
}
if (itemsUpdated.length) {
await this.db.updateLibraryItems(itemsUpdated)
await Database.updateBulkBooks(itemsUpdated.map(i => i.media))
SocketAuthority.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
}
@ -848,21 +941,53 @@ class LibraryController {
res.json(payload)
}
middleware(req, res, next) {
getOPMLFile(req, res) {
const opmlText = this.podcastManager.generateOPMLFileText(req.libraryItems)
res.type('application/xml')
res.send(opmlText)
}
/**
* TODO: Replace with middlewareNew
* @param {*} req
* @param {*} res
* @param {*} next
*/
async middleware(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
return res.sendStatus(404)
return res.sendStatus(403)
}
const library = this.db.libraries.find(lib => lib.id === req.params.id)
const library = await Database.models.library.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
req.library = library
req.libraryItems = this.db.libraryItems.filter(li => {
req.libraryItems = Database.libraryItems.filter(li => {
return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
})
next()
}
/**
* Middleware that is not using libraryItems from memory
* @param {*} req
* @param {*} res
* @param {*} next
*/
async middlewareNew(req, res, next) {
if (!req.user.checkCanAccessLibrary(req.params.id)) {
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
return res.sendStatus(403)
}
const library = await Database.models.library.getOldById(req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
req.library = library
next()
}
}
module.exports = new LibraryController()

View file

@ -1,16 +1,19 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
const { reqSupportsWebp } = require('../utils/index')
const { ScanResult } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
class LibraryItemController {
constructor() { }
// Example expand with authors: api/items/:id?expanded=1&include=authors
findOne(req, res) {
async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded()
@ -22,14 +25,14 @@ class LibraryItemController {
}
if (includeEntities.includes('rssfeed')) {
const feedData = this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData ? feedData.toJSONMinified() : null
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null
}
if (item.mediaType == 'book') {
if (includeEntities.includes('authors')) {
item.media.metadata.authors = item.media.metadata.authors.map(au => {
var author = this.db.authors.find(_au => _au.id === au.id)
var author = Database.authors.find(_au => _au.id === au.id)
if (!author) return null
return {
...author
@ -59,7 +62,7 @@ class LibraryItemController {
const hasUpdates = libraryItem.update(req.body)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`)
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json(libraryItem.toJSON())
@ -85,7 +88,9 @@ class LibraryItemController {
}
const libraryItemPath = req.libraryItem.path
const filename = `${req.libraryItem.media.metadata.title}.zip`
const itemTitle = req.libraryItem.media.metadata.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
const filename = `${itemTitle}.zip`
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
}
@ -95,6 +100,7 @@ class LibraryItemController {
async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body
// Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) {
await this.cacheManager.purgeCoverCache(libraryItem.id)
@ -102,7 +108,7 @@ class LibraryItemController {
// Book specific
if (libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
}
// Podcast specific
@ -137,7 +143,7 @@ class LibraryItemController {
}
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json({
@ -172,7 +178,7 @@ class LibraryItemController {
return res.status(500).send('Unknown error occurred')
}
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json({
success: true,
@ -192,7 +198,7 @@ class LibraryItemController {
return res.status(500).send(validationResult.error)
}
if (validationResult.updated) {
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
res.json({
@ -208,7 +214,7 @@ class LibraryItemController {
if (libraryItem.media.coverPath) {
libraryItem.updateMediaCover('')
await this.cacheManager.purgeCoverCache(libraryItem.id)
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
@ -280,7 +286,7 @@ class LibraryItemController {
return res.sendStatus(500)
}
libraryItem.media.updateAudioTracks(orderedFileData)
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
@ -307,7 +313,7 @@ class LibraryItemController {
return res.sendStatus(500)
}
const itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id))
const itemsToDelete = Database.libraryItems.filter(li => libraryItemIds.includes(li.id))
if (!itemsToDelete.length) {
return res.sendStatus(404)
}
@ -336,15 +342,15 @@ class LibraryItemController {
for (let i = 0; i < updatePayloads.length; i++) {
var mediaPayload = updatePayloads[i].mediaPayload
var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id)
var libraryItem = Database.libraryItems.find(_li => _li.id === updatePayloads[i].id)
if (!libraryItem) return null
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
var hasUpdates = libraryItem.media.update(mediaPayload)
if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
itemsUpdated++
}
@ -364,7 +370,7 @@ class LibraryItemController {
}
const libraryItems = []
libraryItemIds.forEach((lid) => {
const li = this.db.libraryItems.find(_li => _li.id === lid)
const li = Database.libraryItems.find(_li => _li.id === lid)
if (li) libraryItems.push(li.toJSONExpanded())
})
res.json({
@ -379,20 +385,23 @@ class LibraryItemController {
return res.sendStatus(403)
}
var itemsUpdated = 0
var itemsUnmatched = 0
let itemsUpdated = 0
let itemsUnmatched = 0
var matchData = req.body
var options = matchData.options || {}
var items = matchData.libraryItemIds
if (!items || !items.length) {
return res.sendStatus(500)
const options = req.body.options || {}
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200)
for (let i = 0; i < items.length; i++) {
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
for (const libraryItem of libraryItems) {
const matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
if (matchResult.updated) {
itemsUpdated++
} else if (matchResult.warning) {
@ -400,7 +409,7 @@ class LibraryItemController {
}
}
var result = {
const result = {
success: itemsUpdated > 0,
updates: itemsUpdated,
unmatched: itemsUnmatched
@ -408,16 +417,31 @@ class LibraryItemController {
SocketAuthority.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
}
// DELETE: api/items/all
async deleteAll(req, res) {
// POST: api/items/batch/scan
async batchScan(req, res) {
if (!req.user.isAdminOrUp) {
Logger.warn('User other than admin attempted to delete all library items', req.user)
Logger.warn('User other than admin attempted to batch scan library items', req.user)
return res.sendStatus(403)
}
Logger.info('Removing all Library Items')
var success = await this.db.recreateLibraryItemsDb()
if (success) res.sendStatus(200)
else res.sendStatus(500)
if (!req.body.libraryItemIds?.length) {
return res.sendStatus(400)
}
const libraryItems = req.body.libraryItemIds.map(lid => Database.getLibraryItem(lid)).filter(li => li)
if (!libraryItems?.length) {
return res.sendStatus(400)
}
res.sendStatus(200)
for (const libraryItem of libraryItems) {
if (libraryItem.isFile) {
Logger.warn(`[LibraryItemController] Re-scanning file library items not yet supported`)
} else {
await this.scanner.scanLibraryItemByRequest(libraryItem)
}
}
}
// POST: api/items/:id/scan (admin)
@ -432,7 +456,7 @@ class LibraryItemController {
return res.sendStatus(500)
}
var result = await this.scanner.scanLibraryItemById(req.libraryItem.id)
const result = await this.scanner.scanLibraryItemByRequest(req.libraryItem)
res.json({
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
})
@ -440,7 +464,7 @@ class LibraryItemController {
getToneMetadataObject(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
Logger.error(`[LibraryItemController] Non-admin user attempted to get tone metadata object`, req.user)
return res.sendStatus(403)
}
@ -472,7 +496,7 @@ class LibraryItemController {
const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
if (wasUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
@ -482,55 +506,197 @@ class LibraryItemController {
})
}
async toneScan(req, res) {
if (!req.libraryItem.media.audioFiles.length) {
return res.sendStatus(404)
/**
* GET api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getFFprobeData(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[LibraryItemController] Non-admin user attempted to get ffprobe data`, req.user)
return res.sendStatus(403)
}
if (req.libraryFile.fileType !== 'audio') {
Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`)
return res.sendStatus(400)
}
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid)
if (!audioFile) {
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404)
}
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
res.json(toneData)
const ffprobeData = await this.scanner.probeAudioFile(audioFile)
res.json(ffprobeData)
}
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.ino)
if (!libraryFile) {
Logger.error(`[LibraryItemController] Unable to delete library file. Not found. "${req.params.ino}"`)
return res.sendStatus(404)
/**
* GET api/items/:id/file/:fileid
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getLibraryFile(req, res) {
const libraryFile = req.libraryFile
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(libraryFile.metadata.path)
}
/**
* DELETE api/items/:id/file/:fileid
*
* @param {express.Request} req
* @param {express.Response} res
*/
async deleteLibraryFile(req, res) {
const libraryFile = req.libraryFile
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file delete at "${libraryFile.metadata.path}"`)
await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
})
req.libraryItem.removeLibraryFile(req.params.ino)
req.libraryItem.removeLibraryFile(req.params.fileid)
if (req.libraryItem.media.removeFileWithInode(req.params.ino)) {
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) {
// If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) {
req.libraryItem.setMissing()
}
}
req.libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(req.libraryItem)
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
/**
* GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
* @param {express.Request} req
* @param {express.Response} res
*/
async downloadLibraryFile(req, res) {
const libraryFile = req.libraryFile
if (!req.user.canDownload) {
Logger.error(`[LibraryItemController] User without download permission attempted to download file "${libraryFile.metadata.path}"`, req.user)
return res.sendStatus(403)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested file download at "${libraryFile.metadata.path}"`)
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${libraryFile.metadata.path}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + libraryFile.metadata.path }).send()
}
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryFile.metadata.path))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
}
/**
* GET api/items/:id/ebook/:fileid?
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino
* fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned
*
* @param {express.Request} req
* @param {express.Response} res
*/
async getEBookFile(req, res) {
let ebookFile = null
if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
} else {
ebookFile = req.libraryItem.media.ebookFile
}
if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`)
return res.sendStatus(404)
}
const ebookFilePath = ebookFile.metadata.path
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${ebookFilePath}`)
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + ebookFilePath }).send()
}
res.sendFile(ebookFilePath)
}
/**
* PATCH api/items/:id/ebook/:fileid/status
* toggle the status of an ebook file.
* if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary
*
* @param {express.Request} req
* @param {express.Response} res
*/
async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id')
}
if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile)
} else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null)
}
req.libraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
res.sendStatus(200)
}
async middleware(req, res, next) {
req.libraryItem = await Database.models.libraryItem.getOldById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) {
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
return res.sendStatus(403)
}
// For library file routes, get the library file
if (req.params.fileid) {
req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404)
}
}
if (req.path.includes('/play')) {
// allow POST requests using /play and /play/:episodeId
} else if (req.method == 'DELETE' && !req.user.canDelete) {
@ -541,7 +707,6 @@ class LibraryItemController {
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
}

View file

@ -1,7 +1,8 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
const { isObject, toNumber } = require('../utils/index')
const { toNumber } = require('../utils/index')
class MeController {
constructor() { }
@ -33,7 +34,7 @@ class MeController {
// GET: api/me/listening-stats
async getListeningStats(req, res) {
var listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
res.json(listeningStats)
}
@ -51,21 +52,21 @@ class MeController {
if (!req.user.removeMediaProgress(req.params.id)) {
return res.sendStatus(200)
}
await this.db.updateEntity('user', req.user)
await Database.removeMediaProgress(req.params.id)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
// PATCH: api/me/progress/:id
async createUpdateMediaProgress(req, res) {
var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id)
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
if (req.user.createUpdateMediaProgress(libraryItem, req.body)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id)
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
@ -73,8 +74,8 @@ class MeController {
// PATCH: api/me/progress/:id/:episodeId
async createUpdateEpisodeMediaProgress(req, res) {
var episodeId = req.params.episodeId
var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id)
const episodeId = req.params.episodeId
const libraryItem = Database.libraryItems.find(ab => ab.id === req.params.id)
if (!libraryItem) {
return res.status(404).send('Item not found')
}
@ -83,9 +84,9 @@ class MeController {
return res.status(404).send('Episode not found')
}
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
if (req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, episodeId)
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
@ -93,24 +94,26 @@ class MeController {
// PATCH: api/me/progress/batch/update
async batchUpdateMediaProgress(req, res) {
var itemProgressPayloads = req.body
if (!itemProgressPayloads || !itemProgressPayloads.length) {
const itemProgressPayloads = req.body
if (!itemProgressPayloads?.length) {
return res.status(400).send('Missing request payload')
}
var shouldUpdate = false
itemProgressPayloads.forEach((itemProgress) => {
var libraryItem = this.db.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
let shouldUpdate = false
for (const itemProgress of itemProgressPayloads) {
const libraryItem = Database.libraryItems.find(li => li.id === itemProgress.libraryItemId) // Make sure this library item exists
if (libraryItem) {
var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)
if (wasUpdated) shouldUpdate = true
if (req.user.createUpdateMediaProgress(libraryItem, itemProgress, itemProgress.episodeId)) {
const mediaProgress = req.user.getMediaProgress(libraryItem.id, itemProgress.episodeId)
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
shouldUpdate = true
}
} else {
Logger.error(`[MeController] batchUpdateMediaProgress: Library Item does not exist ${itemProgress.id}`)
}
})
}
if (shouldUpdate) {
await this.db.updateEntity('user', req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
@ -119,18 +122,18 @@ class MeController {
// POST: api/me/item/:id/bookmark
async createBookmark(req, res) {
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
const { time, title } = req.body
var bookmark = req.user.createBookmark(libraryItem.id, time, title)
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
}
// PATCH: api/me/item/:id/bookmark
async updateBookmark(req, res) {
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
const { time, title } = req.body
if (!req.user.findBookmark(libraryItem.id, time)) {
@ -139,14 +142,14 @@ class MeController {
}
var bookmark = req.user.updateBookmark(libraryItem.id, time, title)
if (!bookmark) return res.sendStatus(500)
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.json(bookmark)
}
// DELETE: api/me/item/:id/bookmark/:time
async removeBookmark(req, res) {
var libraryItem = this.db.libraryItems.find(li => li.id === req.params.id)
var libraryItem = Database.libraryItems.find(li => li.id === req.params.id)
if (!libraryItem) return res.sendStatus(404)
var time = Number(req.params.time)
if (isNaN(time)) return res.sendStatus(500)
@ -156,7 +159,7 @@ class MeController {
return res.sendStatus(404)
}
req.user.removeBookmark(libraryItem.id, time)
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
@ -178,19 +181,19 @@ class MeController {
return res.sendStatus(500)
}
const updatedLocalMediaProgress = []
var numServerProgressUpdates = 0
let numServerProgressUpdates = 0
const updatedServerMediaProgress = []
const localMediaProgress = req.body.localMediaProgress || []
localMediaProgress.forEach(localProgress => {
for (const localProgress of localMediaProgress) {
if (!localProgress.libraryItemId) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress)
return
continue
}
var libraryItem = this.db.getLibraryItem(localProgress.libraryItemId)
const libraryItem = Database.getLibraryItem(localProgress.libraryItemId)
if (!libraryItem) {
Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item`, localProgress)
return
continue
}
let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
@ -199,12 +202,14 @@ class MeController {
Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`)
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
updatedServerMediaProgress.push(mediaProgress)
numServerProgressUpdates++
} else if (mediaProgress.lastUpdate < localProgress.lastUpdate) {
Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`)
req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId)
mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId)
if (mediaProgress) await Database.upsertMediaProgress(mediaProgress)
updatedServerMediaProgress.push(mediaProgress)
numServerProgressUpdates++
} else if (mediaProgress.lastUpdate > localProgress.lastUpdate) {
@ -222,11 +227,10 @@ class MeController {
} else {
Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`)
}
})
}
Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`)
if (numServerProgressUpdates > 0) {
await this.db.updateEntity('user', req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
@ -244,7 +248,7 @@ class MeController {
let itemsInProgress = []
for (const mediaProgress of req.user.mediaProgress) {
if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) {
const libraryItem = this.db.getLibraryItem(mediaProgress.libraryItemId)
const libraryItem = Database.getLibraryItem(mediaProgress.libraryItemId)
if (libraryItem) {
if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find(ep => ep.id === mediaProgress.episodeId)
@ -274,7 +278,7 @@ class MeController {
// GET: api/me/series/:id/remove-from-continue-listening
async removeSeriesFromContinueListening(req, res) {
const series = this.db.series.find(se => se.id === req.params.id)
const series = Database.series.find(se => se.id === req.params.id)
if (!series) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@ -282,7 +286,7 @@ class MeController {
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
@ -290,7 +294,7 @@ class MeController {
// GET: api/me/series/:id/readd-to-continue-listening
async readdSeriesFromContinueListening(req, res) {
const series = this.db.series.find(se => se.id === req.params.id)
const series = Database.series.find(se => se.id === req.params.id)
if (!series) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
@ -298,7 +302,7 @@ class MeController {
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())
@ -308,7 +312,7 @@ class MeController {
async removeItemFromContinueListening(req, res) {
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
if (hasUpdated) {
await this.db.updateEntity('user', req.user)
await Database.updateUser(req.user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.json(req.user.toJSONForBrowser())

View file

@ -2,6 +2,7 @@ const Path = require('path')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const filePerms = require('../utils/filePerms')
const patternValidation = require('../libs/nodeCron/pattern-validation')
@ -23,18 +24,18 @@ class MiscController {
Logger.error('Invalid request, no files')
return res.sendStatus(400)
}
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author
var series = req.body.series
var libraryId = req.body.library
var folderId = req.body.folder
const files = Object.values(req.files)
const title = req.body.title
const author = req.body.author
const series = req.body.series
const libraryId = req.body.library
const folderId = req.body.folder
var library = this.db.libraries.find(lib => lib.id === libraryId)
const library = await Database.models.library.getOldById(libraryId)
if (!library) {
return res.status(404).send(`Library not found with id ${libraryId}`)
}
var folder = library.folders.find(fold => fold.id === folderId)
const folder = library.folders.find(fold => fold.id === folderId)
if (!folder) {
return res.status(404).send(`Folder not found with id ${folderId} in library ${library.name}`)
}
@ -44,8 +45,8 @@ class MiscController {
}
// For setting permissions recursively
var outputDirectory = ''
var firstDirPath = ''
let outputDirectory = ''
let firstDirPath = ''
if (library.isPodcast) { // Podcasts only in 1 folder
outputDirectory = Path.join(folder.fullPath, title)
@ -61,8 +62,7 @@ class MiscController {
}
}
var exists = await fs.pathExists(outputDirectory)
if (exists) {
if (await fs.pathExists(outputDirectory)) {
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
}
@ -111,32 +111,39 @@ class MiscController {
Logger.error('User other than admin attempting to update server settings', req.user)
return res.sendStatus(403)
}
var settingsUpdate = req.body
const settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.status(500).send('Invalid settings update object')
}
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
const madeUpdates = Database.serverSettings.update(settingsUpdate)
if (madeUpdates) {
await Database.updateServerSettings()
// If backup schedule is updated - update backup manager
if (settingsUpdate.backupSchedule !== undefined) {
this.backupManager.updateCronSchedule()
}
await this.db.updateServerSettings()
}
return res.json({
success: true,
serverSettings: this.db.serverSettings.toJSONForBrowser()
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
authorize(req, res) {
/**
* POST: /api/authorize
* Used to authorize an API token
*
* @param {*} req
* @param {*} res
*/
async authorize(req, res) {
if (!req.user) {
Logger.error('Invalid user in authorize')
return res.sendStatus(401)
}
const userResponse = this.auth.getUserLoginResponsePayload(req.user)
const userResponse = await this.auth.getUserLoginResponsePayload(req.user)
res.json(userResponse)
}
@ -147,7 +154,7 @@ class MiscController {
return res.sendStatus(404)
}
const tags = []
this.db.libraryItems.forEach((li) => {
Database.libraryItems.forEach((li) => {
if (li.media.tags && li.media.tags.length) {
li.media.tags.forEach((tag) => {
if (!tags.includes(tag)) tags.push(tag)
@ -176,7 +183,7 @@ class MiscController {
let tagMerged = false
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
for (const li of Database.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
@ -187,7 +194,7 @@ class MiscController {
li.media.tags.push(newTag) // Add new tag
}
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
@ -209,13 +216,13 @@ class MiscController {
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
for (const li of Database.libraryItems) {
if (!li.media.tags || !li.media.tags.length) continue
if (li.media.tags.includes(tag)) {
li.media.tags = li.media.tags.filter(t => t !== tag)
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
@ -233,7 +240,7 @@ class MiscController {
return res.sendStatus(404)
}
const genres = []
this.db.libraryItems.forEach((li) => {
Database.libraryItems.forEach((li) => {
if (li.media.metadata.genres && li.media.metadata.genres.length) {
li.media.metadata.genres.forEach((genre) => {
if (!genres.includes(genre)) genres.push(genre)
@ -262,7 +269,7 @@ class MiscController {
let genreMerged = false
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
for (const li of Database.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
if (li.media.metadata.genres.includes(newGenre)) genreMerged = true // new genre is an existing genre so this is a merge
@ -273,7 +280,7 @@ class MiscController {
li.media.metadata.genres.push(newGenre) // Add new genre
}
Logger.debug(`[MiscController] Rename genre "${genre}" to "${newGenre}" for item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}
@ -295,13 +302,13 @@ class MiscController {
const genre = Buffer.from(decodeURIComponent(req.params.genre), 'base64').toString()
let numItemsUpdated = 0
for (const li of this.db.libraryItems) {
for (const li of Database.libraryItems) {
if (!li.media.metadata.genres || !li.media.metadata.genres.length) continue
if (li.media.metadata.genres.includes(genre)) {
li.media.metadata.genres = li.media.metadata.genres.filter(t => t !== genre)
Logger.debug(`[MiscController] Remove genre "${genre}" from item "${li.media.metadata.title}"`)
await this.db.updateLibraryItem(li)
await Database.updateLibraryItem(li)
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
numItemsUpdated++
}

View file

@ -1,4 +1,5 @@
const Logger = require('../Logger')
const Database = require('../Database')
const { version } = require('../../package.json')
class NotificationController {
@ -7,14 +8,14 @@ class NotificationController {
get(req, res) {
res.json({
data: this.notificationManager.getData(),
settings: this.db.notificationSettings
settings: Database.notificationSettings
})
}
async update(req, res) {
const updated = this.db.notificationSettings.update(req.body)
const updated = Database.notificationSettings.update(req.body)
if (updated) {
await this.db.updateEntity('settings', this.db.notificationSettings)
await Database.updateSetting(Database.notificationSettings)
}
res.sendStatus(200)
}
@ -29,31 +30,31 @@ class NotificationController {
}
async createNotification(req, res) {
const success = this.db.notificationSettings.createNotification(req.body)
const success = Database.notificationSettings.createNotification(req.body)
if (success) {
await this.db.updateEntity('settings', this.db.notificationSettings)
await Database.updateSetting(Database.notificationSettings)
}
res.json(this.db.notificationSettings)
res.json(Database.notificationSettings)
}
async deleteNotification(req, res) {
if (this.db.notificationSettings.removeNotification(req.notification.id)) {
await this.db.updateEntity('settings', this.db.notificationSettings)
if (Database.notificationSettings.removeNotification(req.notification.id)) {
await Database.updateSetting(Database.notificationSettings)
}
res.json(this.db.notificationSettings)
res.json(Database.notificationSettings)
}
async updateNotification(req, res) {
const success = this.db.notificationSettings.updateNotification(req.body)
const success = Database.notificationSettings.updateNotification(req.body)
if (success) {
await this.db.updateEntity('settings', this.db.notificationSettings)
await Database.updateSetting(Database.notificationSettings)
}
res.json(this.db.notificationSettings)
res.json(Database.notificationSettings)
}
async sendNotificationTest(req, res) {
if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
if (!Database.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
const success = await this.notificationManager.sendTestNotification(req.notification)
if (success) res.sendStatus(200)
@ -66,7 +67,7 @@ class NotificationController {
}
if (req.params.id) {
const notification = this.db.notificationSettings.getNotification(req.params.id)
const notification = Database.notificationSettings.getNotification(req.params.id)
if (!notification) {
return res.sendStatus(404)
}

View file

@ -1,5 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Playlist = require('../objects/Playlist')
@ -14,31 +15,32 @@ class PlaylistController {
if (!success) {
return res.status(400).send('Invalid playlist request data')
}
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('playlist', newPlaylist)
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylist(newPlaylist)
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
// GET: api/playlists
findAllForUser(req, res) {
async findAllForUser(req, res) {
const playlistsForUser = await Database.models.playlist.getPlaylistsForUserAndLibrary(req.user.id)
res.json({
playlists: this.db.playlists.filter(p => p.userId === req.user.id).map(p => p.toJSONExpanded(this.db.libraryItems))
playlists: playlistsForUser.map(p => p.toJSONExpanded(Database.libraryItems))
})
}
// GET: api/playlists/:id
findOne(req, res) {
res.json(req.playlist.toJSONExpanded(this.db.libraryItems))
res.json(req.playlist.toJSONExpanded(Database.libraryItems))
}
// PATCH: api/playlists/:id
async update(req, res) {
const playlist = req.playlist
let wasUpdated = playlist.update(req.body)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
if (wasUpdated) {
await this.db.updateEntity('playlist', playlist)
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
@ -47,8 +49,8 @@ class PlaylistController {
// DELETE: api/playlists/:id
async delete(req, res) {
const playlist = req.playlist
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
await this.db.removeEntity('playlist', playlist.id)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
res.sendStatus(200)
}
@ -62,7 +64,7 @@ class PlaylistController {
return res.status(400).send('Request body has no libraryItemId')
}
const libraryItem = this.db.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
const libraryItem = Database.libraryItems.find(li => li.id === itemToAdd.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Library item not found')
}
@ -80,8 +82,16 @@ class PlaylistController {
}
playlist.addItem(itemToAdd.libraryItemId, itemToAdd.episodeId)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
await this.db.updateEntity('playlist', playlist)
const playlistMediaItem = {
playlistId: playlist.id,
mediaItemId: itemToAdd.episodeId || libraryItem.media.id,
mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book',
order: playlist.items.length
}
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylistMediaItem(playlistMediaItem)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
res.json(jsonExpanded)
}
@ -99,15 +109,15 @@ class PlaylistController {
playlist.removeItem(itemToRemove.libraryItemId, itemToRemove.episodeId)
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
// Playlist is removed when there are no items
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await this.db.removeEntity('playlist', playlist.id)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
await this.db.updateEntity('playlist', playlist)
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
@ -122,20 +132,34 @@ class PlaylistController {
}
const itemsToAdd = req.body.items
let hasUpdated = false
let order = playlist.items.length
const playlistMediaItems = []
for (const item of itemsToAdd) {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
const libraryItem = Database.getLibraryItem(item.libraryItemId)
if (!libraryItem) {
return res.status(400).send('Item not found with id ' + item.libraryItemId)
}
if (!playlist.containsItem(item)) {
playlistMediaItems.push({
playlistId: playlist.id,
mediaItemId: item.episodeId || libraryItem.media.id, // podcastEpisodeId or bookId
mediaItemType: item.episodeId ? 'podcastEpisode' : 'book',
order: order++
})
playlist.addItem(item.libraryItemId, item.episodeId)
hasUpdated = true
}
}
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
if (hasUpdated) {
await this.db.updateEntity('playlist', playlist)
await Database.createBulkPlaylistMediaItems(playlistMediaItems)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
res.json(jsonExpanded)
@ -153,21 +177,22 @@ class PlaylistController {
if (!item.libraryItemId) {
return res.status(400).send('Item does not have libraryItemId')
}
if (playlist.containsItem(item)) {
playlist.removeItem(item.libraryItemId, item.episodeId)
hasUpdated = true
}
}
const jsonExpanded = playlist.toJSONExpanded(this.db.libraryItems)
const jsonExpanded = playlist.toJSONExpanded(Database.libraryItems)
if (hasUpdated) {
// Playlist is removed when there are no items
if (!playlist.items.length) {
Logger.info(`[PlaylistController] Playlist "${playlist.name}" has no more items - removing it`)
await this.db.removeEntity('playlist', playlist.id)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded)
} else {
await this.db.updateEntity('playlist', playlist)
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded)
}
}
@ -176,12 +201,12 @@ class PlaylistController {
// POST: api/playlists/collection/:collectionId
async createFromCollection(req, res) {
let collection = this.db.collections.find(c => c.id === req.params.collectionId)
let collection = await Database.models.collection.getById(req.params.collectionId)
if (!collection) {
return res.status(404).send('Collection not found')
}
// Expand collection to get library items
collection = collection.toJSONExpanded(this.db.libraryItems)
collection = collection.toJSONExpanded(Database.libraryItems)
// Filter out library items not accessible to user
const libraryItems = collection.books.filter(item => req.user.checkCanAccessLibraryItem(item))
@ -201,15 +226,15 @@ class PlaylistController {
}
newPlaylist.setData(newPlaylistData)
const jsonExpanded = newPlaylist.toJSONExpanded(this.db.libraryItems)
await this.db.insertEntity('playlist', newPlaylist)
const jsonExpanded = newPlaylist.toJSONExpanded(Database.libraryItems)
await Database.createPlaylist(newPlaylist)
SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded)
res.json(jsonExpanded)
}
middleware(req, res, next) {
async middleware(req, res, next) {
if (req.params.id) {
const playlist = this.db.playlists.find(p => p.id === req.params.id)
const playlist = await Database.models.playlist.getById(req.params.id)
if (!playlist) {
return res.status(404).send('Playlist not found')
}

View file

@ -1,5 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
@ -18,7 +19,7 @@ class PodcastController {
}
const payload = req.body
const library = this.db.libraries.find(lib => lib.id === payload.libraryId)
const library = await Database.models.library.getOldById(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found')
@ -33,7 +34,7 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path)
// Check if a library item with this podcast folder exists already
const existingLibraryItem = this.db.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
const existingLibraryItem = Database.libraryItems.find(li => li.path === podcastPath && li.libraryId === library.id)
if (existingLibraryItem) {
Logger.error(`[PodcastController] Podcast already exists with name "${existingLibraryItem.media.metadata.title}" at path "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
@ -80,7 +81,7 @@ class PodcastController {
}
}
await this.db.insertLibraryItem(libraryItem)
await Database.createLibraryItem(libraryItem)
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSONExpanded())
@ -109,7 +110,7 @@ class PodcastController {
res.json({ podcast })
}
async getOPMLFeeds(req, res) {
async getFeedsFromOPMLText(req, res) {
if (!req.body.opmlText) {
return res.sendStatus(400)
}
@ -199,7 +200,7 @@ class PodcastController {
const overrideDetails = req.query.override === '1'
const episodesUpdated = await this.scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
if (episodesUpdated) {
await this.db.updateLibraryItem(req.libraryItem)
await Database.updateLibraryItem(req.libraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
}
@ -216,9 +217,8 @@ class PodcastController {
return res.status(404).send('Episode not found')
}
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)
if (wasUpdated) {
await this.db.updateLibraryItem(libraryItem)
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
@ -241,18 +241,18 @@ class PodcastController {
// DELETE: api/podcasts/:id/episode/:episodeId
async removeEpisode(req, res) {
var episodeId = req.params.episodeId
var libraryItem = req.libraryItem
var hardDelete = req.query.hard === '1'
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1'
var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
}
if (hardDelete) {
var audioFile = episode.audioFile
const audioFile = episode.audioFile
// TODO: this will trigger the watcher. should maybe handle this gracefully
await fs.remove(audioFile.metadata.path).then(() => {
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
@ -263,17 +263,43 @@ class PodcastController {
// Remove episode from Podcast and library file
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
if (episodeRemoved && episodeRemoved.audioFile) {
if (episodeRemoved?.audioFile) {
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
}
await this.db.updateLibraryItem(libraryItem)
// Update/remove playlists that had this podcast episode
const playlistsWithEpisode = await Database.models.playlist.getPlaylistsForMediaItemIds([episodeId])
for (const playlist of playlistsWithEpisode) {
playlist.removeItem(libraryItem.id, episodeId)
// If playlist is now empty then remove it
if (!playlist.items.length) {
Logger.info(`[PodcastController] Playlist "${playlist.name}" has no more items - removing it`)
await Database.removePlaylist(playlist.id)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', playlist.toJSONExpanded(Database.libraryItems))
} else {
await Database.updatePlaylist(playlist)
SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', playlist.toJSONExpanded(Database.libraryItems))
}
}
// Remove media progress for this episode
const mediaProgressRemoved = await Database.models.mediaProgress.destroy({
where: {
mediaItemId: episode.id
}
})
if (mediaProgressRemoved) {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
}
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
middleware(req, res, next) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
const item = Database.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
if (!item.isPodcast) {

View file

@ -1,5 +1,5 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
class RSSFeedController {
constructor() { }
@ -8,7 +8,7 @@ class RSSFeedController {
async openRSSFeedForItem(req, res) {
const options = req.body || {}
const item = this.db.libraryItems.find(li => li.id === req.params.itemId)
const item = Database.libraryItems.find(li => li.id === req.params.itemId)
if (!item) return res.sendStatus(404)
// Check user can access this library item
@ -30,7 +30,7 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
@ -45,7 +45,7 @@ class RSSFeedController {
async openRSSFeedForCollection(req, res) {
const options = req.body || {}
const collection = this.db.collections.find(li => li.id === req.params.collectionId)
const collection = await Database.models.collection.getById(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist
@ -55,12 +55,12 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems)
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length)
// Check collection has audio tracks
@ -79,7 +79,7 @@ class RSSFeedController {
async openRSSFeedForSeries(req, res) {
const options = req.body || {}
const series = this.db.series.find(se => se.id === req.params.seriesId)
const series = Database.series.find(se => se.id === req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist
@ -89,14 +89,14 @@ class RSSFeedController {
}
// Check that this slug is not being used for another feed (slug will also be the Feed id)
if (this.rssFeedManager.feeds[options.slug]) {
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
return res.status(400).send('Slug already in use')
}
const seriesJson = series.toJSON()
// Get books in series that have audio tracks
seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
// Check series has audio tracks
if (!seriesJson.books.length) {
@ -111,10 +111,8 @@ class RSSFeedController {
}
// POST: api/feeds/:id/close
async closeRSSFeed(req, res) {
await this.rssFeedManager.closeRssFeed(req.params.id)
res.sendStatus(200)
closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res)
}
middleware(req, res, next) {
@ -123,14 +121,6 @@ class RSSFeedController {
return res.sendStatus(403)
}
if (req.params.id) {
const feed = this.rssFeedManager.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
}
}
next()
}
}

View file

@ -1,9 +1,20 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
class SeriesController {
constructor() { }
/**
* @deprecated
* /api/series/:id
*
* TODO: Update mobile app to use /api/libraries/:id/series/:seriesId API route instead
* Series are not library specific so we need to know what the library id is
*
* @param {*} req
* @param {*} res
*/
async findOne(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v)
@ -11,7 +22,7 @@ class SeriesController {
// Add progress map with isFinished flag
if (include.includes('progress')) {
const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id))
const libraryItemsInSeries = req.libraryItemsInSeries
const libraryItemsFinished = libraryItemsInSeries.filter(li => {
const mediaProgress = req.user.getMediaProgress(li.id)
return mediaProgress && mediaProgress.isFinished
@ -24,18 +35,18 @@ class SeriesController {
}
if (include.includes('rssfeed')) {
const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id)
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null
}
return res.json(seriesJson)
res.json(seriesJson)
}
async search(req, res) {
var q = (req.query.q || '').toLowerCase()
if (!q) return res.json([])
var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25
var series = this.db.series.filter(se => se.name.toLowerCase().includes(q))
var series = Database.series.filter(se => se.name.toLowerCase().includes(q))
series = series.slice(0, limit)
res.json({
results: series
@ -45,16 +56,26 @@ class SeriesController {
async update(req, res) {
const hasUpdated = req.series.update(req.body)
if (hasUpdated) {
await this.db.updateEntity('series', req.series)
await Database.updateSeries(req.series)
SocketAuthority.emitter('series_updated', req.series.toJSON())
}
res.json(req.series.toJSON())
}
middleware(req, res, next) {
const series = this.db.series.find(se => se.id === req.params.id)
const series = Database.series.find(se => se.id === req.params.id)
if (!series) return res.sendStatus(404)
/**
* Filter out any library items not accessible to user
*/
const libraryItems = Database.libraryItems.filter(li => li.media.metadata.hasSeries?.(series.id))
const libraryItemsAccessible = libraryItems.filter(li => req.user.checkCanAccessLibraryItem(li))
if (libraryItems.length && !libraryItemsAccessible.length) {
Logger.warn(`[SeriesController] User attempted to access series "${series.id}" without access to any of the books`, req.user)
return res.sendStatus(403)
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[SeriesController] User attempted to delete without permission`, req.user)
return res.sendStatus(403)
@ -64,6 +85,7 @@ class SeriesController {
}
req.series = series
req.libraryItemsInSeries = libraryItemsAccessible
next()
}
}

View file

@ -1,4 +1,5 @@
const Logger = require('../Logger')
const Database = require('../Database')
const { toNumber } = require('../utils/index')
class SessionController {
@ -42,17 +43,17 @@ class SessionController {
res.json(payload)
}
getOpenSessions(req, res) {
async getOpenSessions(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[SessionController] getOpenSessions: Non-admin user requested open session data ${req.user.id}/"${req.user.username}"`)
return res.sendStatus(404)
}
const minifiedUserObjects = await Database.models.user.getMinifiedUserObjects()
const openSessions = this.playbackSessionManager.sessions.map(se => {
const user = this.db.users.find(u => u.id === se.userId) || null
return {
...se.toJSON(),
user: user ? { id: user.id, username: user.username } : null
user: minifiedUserObjects.find(u => u.id === se.userId) || null
}
})
@ -62,7 +63,7 @@ class SessionController {
}
getOpenSession(req, res) {
var libraryItem = this.db.getLibraryItem(req.session.libraryItemId)
var libraryItem = Database.getLibraryItem(req.session.libraryItemId)
var sessionForClient = req.session.toJSONForClient(libraryItem)
res.json(sessionForClient)
}
@ -74,7 +75,9 @@ class SessionController {
// POST: api/session/:id/close
close(req, res) {
this.playbackSessionManager.closeSessionRequest(req.user, req.session, req.body, res)
let syncData = req.body
if (syncData && !Object.keys(syncData).length) syncData = null
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res)
}
// DELETE: api/session/:id
@ -85,13 +88,13 @@ class SessionController {
await this.playbackSessionManager.removeSession(req.session.id)
}
await this.db.removeEntity('session', req.session.id)
await Database.removePlaybackSession(req.session.id)
res.sendStatus(200)
}
// POST: api/session/local
syncLocal(req, res) {
this.playbackSessionManager.syncLocalSessionRequest(req.user, req.body, res)
this.playbackSessionManager.syncLocalSessionRequest(req, res)
}
// POST: api/session/local-all
@ -113,7 +116,7 @@ class SessionController {
}
async middleware(req, res, next) {
const playbackSession = await this.db.getPlaybackSession(req.params.id)
const playbackSession = await Database.getPlaybackSession(req.params.id)
if (!playbackSession) {
Logger.error(`[SessionController] Unable to find playback session with id=${req.params.id}`)
return res.sendStatus(404)

View file

@ -1,4 +1,5 @@
const Logger = require('../Logger')
const Database = require('../Database')
class ToolsController {
constructor() { }
@ -65,7 +66,7 @@ class ToolsController {
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = this.db.getLibraryItem(libraryItemId)
const libraryItem = Database.getLibraryItem(libraryItemId)
if (!libraryItem) {
Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`)
return res.sendStatus(404)
@ -105,7 +106,7 @@ class ToolsController {
}
if (req.params.id) {
const item = this.db.libraryItems.find(li => li.id === req.params.id)
const item = Database.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media) return res.sendStatus(404)
// Check user can access this library item

View file

@ -1,52 +1,63 @@
const uuidv4 = require("uuid").v4
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const User = require('../objects/user/User')
const { getId, toNumber } = require('../utils/index')
const { toNumber } = require('../utils/index')
class UserController {
constructor() { }
findAll(req, res) {
async findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
const includes = (req.query.include || '').split(',').map(i => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.models.user.getOldUsers()
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) {
for (const user of users) {
const userSessions = await Database.getPlaybackSessions({ userId: user.id })
user.latestSession = userSessions.sort((a, b) => b.updatedAt - a.updatedAt).shift() || null
}
}
res.json({
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks
users: this.db.users.map(u => u.toJSONForBrowser(hideRootToken, true))
users
})
}
findOne(req, res) {
async findOne(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error('User other than admin attempting to get user', req.user)
return res.sendStatus(403)
}
const user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
res.json(this.userJsonWithItemProgressDetails(req.reqUser, !req.user.isRoot))
}
async create(req, res) {
var account = req.body
const account = req.body
const username = account.username
var username = account.username
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
const usernameExists = await Database.models.user.getUserByUsername(username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
account.id = getId('usr')
account.id = uuidv4()
account.pash = await this.auth.hashPass(account.password)
delete account.password
account.token = await this.auth.generateAccessToken(account)
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertEntity('user', newUser)
const newUser = new User(account)
const success = await Database.createUser(newUser)
if (success) {
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
res.json({
@ -58,7 +69,7 @@ class UserController {
}
async update(req, res) {
var user = req.reqUser
const user = req.reqUser
if (user.type === 'root' && !req.user.isRoot) {
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
@ -69,7 +80,7 @@ class UserController {
var shouldUpdateToken = false
if (account.username !== undefined && account.username !== user.username) {
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
const usernameExists = await Database.models.user.getUserByUsername(account.username)
if (usernameExists) {
return res.status(500).send('Username already taken')
}
@ -82,13 +93,12 @@ class UserController {
delete account.password
}
var hasUpdated = user.update(account)
if (hasUpdated) {
if (user.update(account)) {
if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
}
await this.db.updateEntity('user', user)
await Database.updateUser(user)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
}
@ -112,13 +122,13 @@ class UserController {
// Todo: check if user is logged in and cancel streams
// Remove user playlists
const userPlaylists = this.db.playlists.filter(p => p.userId === user.id)
const userPlaylists = await Database.models.playlist.getPlaylistsForUserAndLibrary(user.id)
for (const playlist of userPlaylists) {
await this.db.removeEntity('playlist', playlist.id)
await Database.removePlaylist(playlist.id)
}
const userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id)
await Database.removeUser(user.id)
SocketAuthority.adminEmitter('user_removed', userJson)
res.json({
success: true
@ -152,40 +162,6 @@ class UserController {
res.json(listeningStats)
}
// POST: api/users/:id/purge-media-progress
async purgeMediaProgress(req, res) {
const user = req.reqUser
if (user.type === 'root' && !req.user.isRoot) {
Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username)
return res.sendStatus(403)
}
var progressPurged = 0
user.mediaProgress = user.mediaProgress.filter(mp => {
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
if (!libraryItem) {
progressPurged++
return false
} else if (mp.episodeId) {
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null
if (!episode) { // Episode not found
progressPurged++
return false
}
}
return true
})
if (progressPurged) {
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
await this.db.updateEntity('user', user)
SocketAuthority.adminEmitter('user_updated', user.toJSONForBrowser())
}
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
}
// POST: api/users/online (admin)
async getOnlineUsers(req, res) {
if (!req.user.isAdminOrUp) {
@ -198,7 +174,7 @@ class UserController {
})
}
middleware(req, res, next) {
async middleware(req, res, next) {
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
@ -206,7 +182,7 @@ class UserController {
}
if (req.params.id) {
req.reqUser = this.db.users.find(u => u.id === req.params.id)
req.reqUser = await Database.models.user.getUserById(req.params.id)
if (!req.reqUser) {
return res.sendStatus(404)
}

View file

@ -0,0 +1,16 @@
const itemDb = require('../db/item.db')
const getLibraryItem = async (req, res) => {
let libraryItem = null
if (req.query.expanded == 1) {
libraryItem = await itemDb.getLibraryItemExpanded(req.params.id)
} else {
libraryItem = await itemDb.getLibraryItemMinified(req.params.id)
}
res.json(libraryItem)
}
module.exports = {
getLibraryItem
}

View file

@ -0,0 +1,80 @@
/**
* TODO: Unused for testing
*/
const { Sequelize } = require('sequelize')
const Database = require('../Database')
const getLibraryItemMinified = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, {
include: [
{
model: Database.models.book,
attributes: [
'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags'
],
include: [
{
model: Database.models.author,
attributes: ['id', 'name'],
through: {
attributes: []
}
},
{
model: Database.models.series,
attributes: ['id', 'name'],
through: {
attributes: ['sequence']
}
}
]
},
{
model: Database.models.podcast,
attributes: [
'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags',
[Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes']
]
}
]
})
}
const getLibraryItemExpanded = (libraryItemId) => {
return Database.models.libraryItem.findByPk(libraryItemId, {
include: [
{
model: Database.models.book,
include: [
{
model: Database.models.author,
through: {
attributes: []
}
},
{
model: Database.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: Database.models.podcast,
include: [
{
model: Database.models.podcastEpisode
}
]
},
'libraryFolder',
'library'
]
})
}
module.exports = {
getLibraryItemMinified,
getLibraryItemExpanded
}

View file

@ -1,7 +0,0 @@
Copyright 2021 James BonTempo (jamesbontempo@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,489 +0,0 @@
"use strict";
const {
existsSync,
mkdirSync,
readFileSync,
writeFileSync
} = require("graceful-fs");
const {
join,
resolve
} = require("path");
const {
aggregateStoreData,
aggregateStoreDataSync,
distributeStoreData,
distributeStoreDataSync,
deleteStoreData,
deleteStoreDataSync,
dropEverything,
dropEverythingSync,
getStoreNames,
getStoreNamesSync,
insertStoreData,
insertStoreDataSync,
insertFileData,
selectStoreData,
selectStoreDataSync,
statsStoreData,
statsStoreDataSync,
updateStoreData,
updateStoreDataSync
} = require("./njodb");
const {
Randomizer,
Reducer,
Result
} = require("./objects");
const {
validateArray,
validateFunction,
validateName,
validateObject,
validatePath,
validateSize
} = require("./validators");
const defaults = {
"datadir": "data",
"dataname": "data",
"datastores": 5,
"tempdir": "tmp",
"lockoptions": {
"stale": 5000,
"update": 1000,
"retries": {
"retries": 5000,
"minTimeout": 250,
"maxTimeout": 5000,
"factor": 0.15,
"randomize": false
}
}
};
const mergeProperties = (defaults, userProperties) => {
var target = Object.assign({}, defaults);
for (let key of Object.keys(userProperties)) {
if (Object.prototype.hasOwnProperty.call(target, key)) {
if (typeof userProperties[key] !== 'object' && !Array.isArray(userProperties[key])) {
Object.assign(target, { [key]: userProperties[key] });
} else {
target[key] = mergeProperties(target[key], userProperties[key]);
}
}
}
return target;
}
const saveProperties = (root, properties) => {
properties = {
"datadir": properties.datadir,
"dataname": properties.dataname,
"datastores": properties.datastores,
"tempdir": properties.tempdir,
"lockoptions": properties.lockoptions
};
const propertiesFile = join(root, "njodb.properties");
writeFileSync(propertiesFile, JSON.stringify(properties, null, 4));
return properties;
}
process.on("uncaughtException", error => {
if (error.code === "ECOMPROMISED") {
console.error(Object.assign(new Error("Stale lock or attempt to update it after release"), { code: error.code }));
} else {
throw error;
}
});
class Database {
constructor(root, properties = {}) {
validateObject(properties);
this.properties = {};
if (root !== undefined && root !== null) {
validateName(root);
this.properties.root = root;
} else {
this.properties.root = process.cwd();
}
if (!existsSync(this.properties.root)) mkdirSync(this.properties.root);
const propertiesFile = join(this.properties.root, "njodb.properties");
if (existsSync(propertiesFile)) {
this.setProperties(JSON.parse(readFileSync(propertiesFile)));
} else {
this.setProperties(mergeProperties(defaults, properties));
}
if (!existsSync(this.properties.datapath)) mkdirSync(this.properties.datapath);
if (!existsSync(this.properties.temppath)) mkdirSync(this.properties.temppath);
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
return this;
}
// Database management methods
getProperties() {
return this.properties;
}
setProperties(properties) {
validateObject(properties);
this.properties.datadir = (validateName(properties.datadir)) ? properties.datadir : defaults.datadir;
this.properties.dataname = (validateName(properties.dataname)) ? properties.dataname : defaults.dataname;
this.properties.datastores = (validateSize(properties.datastores)) ? properties.datastores : defaults.datastores;
this.properties.tempdir = (validateName(properties.tempdir)) ? properties.tempdir : defaults.tempdir;
this.properties.lockoptions = (validateObject(properties.lockoptions)) ? properties.lockoptions : defaults.lockoptions;
this.properties.datapath = join(this.properties.root, this.properties.datadir);
this.properties.temppath = join(this.properties.root, this.properties.tempdir);
saveProperties(this.properties.root, this.properties);
return this.properties;
}
async stats() {
var stats = {
root: resolve(this.properties.root),
data: resolve(this.properties.datapath),
temp: resolve(this.properties.temppath)
};
var promises = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
promises.push(statsStoreData(storepath, this.properties.lockoptions));
}
const results = await Promise.all(promises);
return Object.assign(stats, Reducer("stats", results));
}
statsSync() {
var stats = {
root: resolve(this.properties.root),
data: resolve(this.properties.datapath),
temp: resolve(this.properties.temppath)
};
var results = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
results.push(statsStoreDataSync(storepath));
}
return Object.assign(stats, Reducer("stats", results));
}
async grow() {
this.properties.datastores++;
const results = await distributeStoreData(this.properties);
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
}
growSync() {
this.properties.datastores++;
const results = distributeStoreDataSync(this.properties);
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
}
async shrink() {
if (this.properties.datastores > 1) {
this.properties.datastores--;
const results = await distributeStoreData(this.properties);
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
} else {
throw new Error("Database cannot shrink any further");
}
}
shrinkSync() {
if (this.properties.datastores > 1) {
this.properties.datastores--;
const results = distributeStoreDataSync(this.properties);
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
} else {
throw new Error("Database cannot shrink any further");
}
}
async resize(size) {
validateSize(size);
this.properties.datastores = size;
const results = await distributeStoreData(this.properties);
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
}
resizeSync(size) {
validateSize(size);
this.properties.datastores = size;
const results = distributeStoreDataSync(this.properties);
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
saveProperties(this.properties.root, this.properties);
return results;
}
async drop() {
const results = await dropEverything(this.properties);
return Reducer("drop", results);
}
dropSync() {
const results = dropEverythingSync(this.properties);
return Reducer("drop", results);
}
// Data manipulation methods
async insert(data) {
validateArray(data);
var promises = [];
var records = [];
for (let i = 0; i < this.properties.datastores; i++) {
records[i] = "";
}
for (let i = 0; i < data.length; i++) {
records[i % this.properties.datastores] += JSON.stringify(data[i]) + "\n";
}
const randomizer = Randomizer(Array.from(Array(this.properties.datastores).keys()), false);
for (var j = 0; j < records.length; j++) {
if (records[j] !== "") {
const storenumber = randomizer.next();
const storename = [this.properties.dataname, storenumber, "json"].join(".");
const storepath = join(this.properties.datapath, storename)
promises.push(insertStoreData(storepath, records[j], this.properties.lockoptions));
}
}
const results = await Promise.all(promises);
this.properties.storenames = await getStoreNames(this.properties.datapath, this.properties.dataname);
return Reducer("insert", results);
}
insertSync(data) {
validateArray(data);
var results = [];
var records = [];
for (let i = 0; i < this.properties.datastores; i++) {
records[i] = "";
}
for (let i = 0; i < data.length; i++) {
records[i % this.properties.datastores] += JSON.stringify(data[i]) + "\n";
}
const randomizer = Randomizer(Array.from(Array(this.properties.datastores).keys()), false);
for (var j = 0; j < records.length; j++) {
if (records[j] !== "") {
const storenumber = randomizer.next();
const storename = [this.properties.dataname, storenumber, "json"].join(".");
const storepath = join(this.properties.datapath, storename)
results.push(insertStoreDataSync(storepath, records[j], this.properties.lockoptions));
}
}
this.properties.storenames = getStoreNamesSync(this.properties.datapath, this.properties.dataname);
return Reducer("insert", results);
}
async insertFile(file) {
validatePath(file);
const results = await insertFileData(file, this.properties.datapath, this.properties.storenames, this.properties.lockoptions);
return results;
}
insertFileSync(file) {
validatePath(file);
const data = readFileSync(file, "utf8").split("\n");
var records = [];
var results = Result("insertFile");
for (var record of data) {
record = record.trim()
results.lines++;
if (record.length > 0) {
try {
records.push(JSON.parse(record));
} catch (error) {
results.errors.push({ error: error.message, line: results.lines, data: record });
}
} else {
results.blanks++;
}
}
return Object.assign(results, this.insertSync(records));
}
async select(match, project) {
validateFunction(match);
if (project) validateFunction(project);
var promises = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
promises.push(selectStoreData(storepath, match, project, this.properties.lockoptions));
}
const results = await Promise.all(promises);
return Reducer("select", results);
}
selectSync(match, project) {
validateFunction(match);
if (project) validateFunction(project);
var results = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
results.push(selectStoreDataSync(storepath, match, project));
}
return Reducer("select", results);
}
async update(match, update) {
validateFunction(match);
validateFunction(update);
var promises = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
const tempstorename = [storename, Date.now(), "tmp"].join(".");
const tempstorepath = join(this.properties.temppath, tempstorename);
promises.push(updateStoreData(storepath, match, update, tempstorepath, this.properties.lockoptions));
}
const results = await Promise.all(promises);
return Reducer("update", results);
}
updateSync(match, update) {
validateFunction(match);
validateFunction(update);
var results = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
const tempstorename = [storename, Date.now(), "tmp"].join(".");
const tempstorepath = join(this.properties.temppath, tempstorename);
results.push(updateStoreDataSync(storepath, match, update, tempstorepath));
}
return Reducer("update", results);
}
async delete(match) {
validateFunction(match);
var promises = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
const tempstorename = [storename, Date.now(), "tmp"].join(".");
const tempstorepath = join(this.properties.temppath, tempstorename);
promises.push(deleteStoreData(storepath, match, tempstorepath, this.properties.lockoptions));
}
const results = await Promise.all(promises);
return Reducer("delete", results);
}
deleteSync(match) {
validateFunction(match);
var results = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
const tempstorename = [storename, Date.now(), "tmp"].join(".");
const tempstorepath = join(this.properties.temppath, tempstorename);
results.push(deleteStoreDataSync(storepath, match, tempstorepath));
}
return Reducer("delete", results);
}
async aggregate(match, index, project) {
validateFunction(match);
validateFunction(index);
if (project) validateFunction(project);
var promises = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
promises.push(aggregateStoreData(storepath, match, index, project, this.properties.lockoptions));
}
const results = await Promise.all(promises);
return Reducer("aggregate", results);
}
aggregateSync(match, index, project) {
validateFunction(match);
validateFunction(index);
if (project) validateFunction(project);
var results = [];
for (const storename of this.properties.storenames) {
const storepath = join(this.properties.datapath, storename);
results.push(aggregateStoreDataSync(storepath, match, index, project));
}
return Reducer("aggregate", results);
}
}
exports.Database = Database;

View file

@ -1,723 +0,0 @@
"use strict";
const {
appendFile,
appendFileSync,
createReadStream,
createWriteStream,
readFileSync,
readdir,
readdirSync,
stat,
statSync,
writeFile
} = require("graceful-fs");
const {
join,
resolve
} = require("path");
const { createInterface } = require("readline");
const { promisify } = require("util");
const {
check,
checkSync,
lock,
lockSync
} = require("../properLockfile");
const {
deleteFile,
deleteFileSync,
deleteDirectory,
deleteDirectorySync,
fileExists,
fileExistsSync,
moveFile,
moveFileSync,
releaseLock,
releaseLockSync,
replaceFile,
replaceFileSync
} = require("./utils");
const {
Handler,
Randomizer,
Result
} = require("./objects");
const filterStoreNames = (files, dataname) => {
var storenames = [];
const re = new RegExp("^" + [dataname, "\\d+", "json"].join(".") + "$");
for (const file of files) {
if (re.test(file)) storenames.push(file);
}
return storenames;
};
const getStoreNames = async (datapath, dataname) => {
const files = await promisify(readdir)(datapath);
return filterStoreNames(files, dataname);
}
const getStoreNamesSync = (datapath, dataname) => {
const files = readdirSync(datapath);
return filterStoreNames(files, dataname);
};
// Database management
const statsStoreData = async (store, lockoptions) => {
var release, stats, results;
release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => {
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
const handler = Handler("stats");
reader.on("line", record => handler.next(record));
reader.on("close", () => resolve(handler.return()));
reader.on("error", error => reject(error));
});
if (await check(store, lockoptions)) await releaseLock(store, release);
results = Object.assign({ store: resolve(store) }, handlerResults)
stats = await promisify(stat)(store);
results.size = stats.size;
results.created = stats.birthtime;
results.modified = stats.mtime;
results.end = Date.now()
return results;
};
const statsStoreDataSync = (store) => {
var file, release, results;
release = lockSync(store);
file = readFileSync(store, "utf8");
if (checkSync(store)) releaseLockSync(store, release);
const data = file.split("\n");
const handler = Handler("stats");
for (var record of data) {
handler.next(record)
}
results = Object.assign({ store: resolve(store) }, handler.return());
const stats = statSync(store);
results.size = stats.size;
results.created = stats.birthtime;
results.modified = stats.mtime;
results.end = Date.now();
return results;
};
const distributeStoreData = async (properties) => {
var results = Result("distribute");
var storepaths = [];
var tempstorepaths = [];
var locks = [];
for (let storename of properties.storenames) {
const storepath = join(properties.datapath, storename);
storepaths.push(storepath);
locks.push(lock(storepath, properties.lockoptions));
}
const releases = await Promise.all(locks);
var writes = [];
var writers = [];
for (let i = 0; i < properties.datastores; i++) {
const tempstorepath = join(properties.temppath, [properties.dataname, i, results.start, "json"].join("."));
tempstorepaths.push(tempstorepath);
await promisify(writeFile)(tempstorepath, "");
writers.push(createWriteStream(tempstorepath, { flags: "r+" }));
}
for (let storename of properties.storenames) {
writes.push(new Promise((resolve, reject) => {
var line = 0;
const store = join(properties.datapath, storename);
const randomizer = Randomizer(Array.from(Array(properties.datastores).keys()), false);
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
reader.on("line", record => {
const storenumber = randomizer.next();
line++;
try {
record = JSON.stringify(JSON.parse(record));
results.records++;
} catch {
results.errors.push({ line: line, data: record });
} finally {
writers[storenumber].write(record + "\n");
}
});
reader.on("close", () => {
resolve(true);
});
reader.on("error", error => {
reject(error);
});
}));
}
await Promise.all(writes);
for (let writer of writers) {
writer.end();
}
var deletes = [];
for (let storepath of storepaths) {
deletes.push(deleteFile(storepath));
}
await Promise.all(deletes);
for (const release of releases) {
release();
}
var moves = [];
for (let i = 0; i < tempstorepaths.length; i++) {
moves.push(moveFile(tempstorepaths[i], join(properties.datapath, [properties.dataname, i, "json"].join("."))))
}
await Promise.all(moves);
results.stores = tempstorepaths.length,
results.end = Date.now();
results.elapsed = results.end - results.start;
return results;
};
const distributeStoreDataSync = (properties) => {
var results = Result("distribute");
var storepaths = [];
var tempstorepaths = [];
var releases = [];
var data = [];
for (let storename of properties.storenames) {
const storepath = join(properties.datapath, storename);
storepaths.push(storepath);
releases.push(lockSync(storepath));
const file = readFileSync(storepath, "utf8").trimEnd();
if (file.length > 0) data = data.concat(file.split("\n"));
}
var records = [];
for (var i = 0; i < data.length; i++) {
try {
data[i] = JSON.stringify(JSON.parse(data[i]));
results.records++;
} catch (error) {
results.errors.push({ line: i, data: data[i] });
} finally {
if (i === i % properties.datastores) records[i] = [];
records[i % properties.datastores] += data[i] + "\n";
}
}
const randomizer = Randomizer(Array.from(Array(properties.datastores).keys()), false);
for (var j = 0; j < records.length; j++) {
const storenumber = randomizer.next();
const tempstorepath = join(properties.temppath, [properties.dataname, storenumber, results.start, "json"].join("."));
tempstorepaths.push(tempstorepath);
appendFileSync(tempstorepath, records[j]);
}
for (let storepath of storepaths) {
deleteFileSync(storepath);
}
for (const release of releases) {
release();
}
for (let i = 0; i < tempstorepaths.length; i++) {
moveFileSync(tempstorepaths[i], join(properties.datapath, [properties.dataname, i, "json"].join(".")));
}
results.stores = tempstorepaths.length,
results.end = Date.now();
results.elapsed = results.end - results.start;
return results;
};
const dropEverything = async (properties) => {
var locks = [];
for (let storename of properties.storenames) {
locks.push(lock(join(properties.datapath, storename), properties.lockoptions));
}
const releases = await Promise.all(locks);
var deletes = [];
for (let storename of properties.storenames) {
deletes.push(deleteFile(join(properties.datapath, storename)));
}
var results = await Promise.all(deletes);
for (const release of releases) {
release();
}
deletes = [
deleteDirectory(properties.temppath),
deleteDirectory(properties.datapath),
deleteFile(join(properties.root, "njodb.properties"))
];
results = results.concat(await Promise.all(deletes));
return results;
}
const dropEverythingSync = (properties) => {
var results = [];
var releases = [];
for (let storename of properties.storenames) {
releases.push(lockSync(join(properties.datapath, storename)));
}
for (let storename of properties.storenames) {
results.push(deleteFileSync(join(properties.datapath, storename)));
}
for (const release of releases) {
release();
}
results.push(deleteDirectorySync(properties.temppath));
results.push(deleteDirectorySync(properties.datapath));
results.push(deleteFileSync(join(properties.root, "njodb.properties")));
return results;
}
// Data manipulation
const insertStoreData = async (store, data, lockoptions) => {
let release, results;
results = Object.assign({ store: resolve(store) }, Result("insert"));
if (await fileExists(store)) release = await lock(store, lockoptions);
await promisify(appendFile)(store, data, "utf8");
if (await check(store, lockoptions)) await releaseLock(store, release);
results.inserted = (data.length > 0) ? data.split("\n").length - 1 : 0;
results.end = Date.now();
return results;
};
const insertStoreDataSync = (store, data) => {
let release, results;
results = Object.assign({ store: resolve(store) }, Result("insert"));
if (fileExistsSync(store)) release = lockSync(store);
appendFileSync(store, data, "utf8");
if (checkSync(store)) releaseLockSync(store, release);
results.inserted = (data.length > 0) ? data.split("\n").length - 1 : 0;
results.end = Date.now();
return results;
};
const insertFileData = async (file, datapath, storenames, lockoptions) => {
let datastores, locks, releases, writers, results;
results = Result("insertFile");
datastores = storenames.length;
locks = [];
writers = [];
for (let storename of storenames) {
const storepath = join(datapath, storename);
locks.push(lock(storepath, lockoptions));
writers.push(createWriteStream(storepath, { flags: "r+" }));
}
releases = await Promise.all(locks);
await new Promise((resolve, reject) => {
const randomizer = Randomizer(Array.from(Array(datastores).keys()), false);
const reader = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
reader.on("line", record => {
record = record.trim();
const storenumber = randomizer.next();
results.lines++;
if (record.length > 0) {
try {
record = JSON.parse(record);
results.inserted++;
} catch (error) {
results.errors.push({ error: error.message, line: results.lines, data: record });
} finally {
writers[storenumber].write(JSON.stringify(record) + "\n");
}
} else {
results.blanks++;
}
});
reader.on("close", () => {
resolve(true);
});
reader.on("error", error => {
reject(error);
});
});
for (const writer of writers) {
writer.end();
}
for (const release of releases) {
release();
}
results.end = Date.now();
results.elapsed = results.end - results.start;
return results;
}
const selectStoreData = async (store, match, project, lockoptions) => {
let release, results;
release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => {
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
const handler = Handler("select", match, project);
reader.on("line", record => handler.next(record));
reader.on("close", () => resolve(handler.return()));
reader.on("error", error => reject(error));
});
if (await check(store, lockoptions)) await releaseLock(store, release);
results = Object.assign({ store: store }, handlerResults);
return results;
};
const selectStoreDataSync = (store, match, project) => {
let file, release, results;
release = lockSync(store);
file = readFileSync(store, "utf8");
if (checkSync(store)) releaseLockSync(store, release);
const records = file.split("\n");
const handler = Handler("select", match, project);
for (var record of records) {
handler.next(record);
}
results = Object.assign({ store: store }, handler.return());
return results;
};
const updateStoreData = async (store, match, update, tempstore, lockoptions) => {
let release, results;
release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => {
const writer = createWriteStream(tempstore);
const handler = Handler("update", match, update);
writer.on("open", () => {
// Reader was opening and closing before writer ever opened
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
reader.on("line", record => {
handler.next(record, writer)
});
reader.on("close", () => {
writer.end();
resolve(handler.return());
});
reader.on("error", error => reject(error));
});
writer.on("error", error => reject(error));
});
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
if (results.updated > 0) {
if (!await replaceFile(store, tempstore)) {
results.errors = [...results.records];
results.updated = 0;
}
} else {
await deleteFile(tempstore);
}
if (await check(store, lockoptions)) await releaseLock(store, release);
results.end = Date.now();
delete results.data;
delete results.records;
return results;
};
const updateStoreDataSync = (store, match, update, tempstore) => {
let file, release, results;
release = lockSync(store);
file = readFileSync(store, "utf8").trimEnd();
if (checkSync(store)) releaseLockSync(store, release);
const records = file.split("\n");
const handler = Handler("update", match, update);
for (var record of records) {
handler.next(record);
}
results = Object.assign({ store: store, tempstore: tempstore }, handler.return());
if (results.updated > 0) {
let append, replace;
try {
appendFileSync(tempstore, results.data.join("\n") + "\n", "utf8");
append = true;
} catch {
append = false;
}
if (append) replace = replaceFileSync(store, tempstore);
if (!(append || replace)) {
results.errors = [...results.records];
results.updated = 0;
}
}
results.end = Date.now();
delete results.data;
delete results.records;
return results;
};
const deleteStoreData = async (store, match, tempstore, lockoptions) => {
let release, results;
release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => {
const writer = createWriteStream(tempstore);
const handler = Handler("delete", match);
writer.on("open", () => {
// Create reader after writer opens otherwise the reader can sometimes close before the writer opens
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
reader.on("line", record => handler.next(record, writer));
reader.on("close", () => {
writer.end();
resolve(handler.return());
});
reader.on("error", error => reject(error));
});
writer.on("error", error => reject(error));
});
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
if (results.deleted > 0) {
if (!await replaceFile(store, tempstore)) {
results.errors = [...results.records];
results.deleted = 0;
}
} else {
await deleteFile(tempstore);
}
if (await check(store, lockoptions)) await releaseLock(store, release);
results.end = Date.now();
delete results.data;
delete results.records;
return results;
};
const deleteStoreDataSync = (store, match, tempstore) => {
let file, release, results;
release = lockSync(store);
file = readFileSync(store, "utf8");
if (checkSync(store)) releaseLockSync(store, release);
const records = file.split("\n");
const handler = Handler("delete", match);
for (var record of records) {
handler.next(record)
}
results = Object.assign({ store: store, tempstore: tempstore }, handler.return());
if (results.deleted > 0) {
let append, replace;
try {
appendFileSync(tempstore, results.data.join("\n") + "\n", "utf8");
append = true;
} catch {
append = false;
}
if (append) replace = replaceFileSync(store, tempstore);
if (!(append || replace)) {
results.errors = [...results.records];
results.updated = 0;
}
}
results.end = Date.now();
delete results.data;
delete results.records;
return results;
};
const aggregateStoreData = async (store, match, index, project, lockoptions) => {
let release, results;
release = await lock(store, lockoptions);
const handlerResults = await new Promise((resolve, reject) => {
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
const handler = Handler("aggregate", match, index, project);
reader.on("line", record => handler.next(record));
reader.on("close", () => resolve(handler.return()));
reader.on("error", error => reject(error));
});
if (await check(store, lockoptions)) releaseLock(store, release);
results = Object.assign({ store: store }, handlerResults);
return results;
}
const aggregateStoreDataSync = (store, match, index, project) => {
let file, release, results;
release = lockSync(store);
file = readFileSync(store, "utf8");
if (checkSync(store)) releaseLockSync(store, release);
const records = file.split("\n");
const handler = Handler("aggregate", match, index, project);
for (var record of records) {
handler.next(record);
}
results = Object.assign({ store: store }, handler.return());
return results;
}
exports.getStoreNames = getStoreNames;
exports.getStoreNamesSync = getStoreNamesSync;
// Database management
exports.statsStoreData = statsStoreData;
exports.statsStoreDataSync = statsStoreDataSync;
exports.distributeStoreData = distributeStoreData;
exports.distributeStoreDataSync = distributeStoreDataSync;
exports.dropEverything = dropEverything;
exports.dropEverythingSync = dropEverythingSync;
// Data manipulation
exports.insertStoreData = insertStoreData;
exports.insertStoreDataSync = insertStoreDataSync;
exports.insertFileData = insertFileData;
exports.selectStoreData = selectStoreData;
exports.selectStoreDataSync = selectStoreDataSync;
exports.updateStoreData = updateStoreData;
exports.updateStoreDataSync = updateStoreDataSync;
exports.deleteStoreData = deleteStoreData;
exports.deleteStoreDataSync = deleteStoreDataSync;
exports.aggregateStoreData = aggregateStoreData;
exports.aggregateStoreDataSync = aggregateStoreDataSync;

View file

@ -1,608 +0,0 @@
"use strict";
const {
convertSize,
max,
min
} = require("./utils");
const Randomizer = (data, replacement) => {
var mutable = [...data];
if (replacement === undefined || typeof replacement !== "boolean") replacement = true;
function _next() {
var selection;
const index = Math.floor(Math.random() * mutable.length);
if (replacement) {
selection = mutable.slice(index, index + 1)[0];
} else {
selection = mutable.splice(index, 1)[0];
if (mutable.length === 0) mutable = [...data];
}
return selection;
}
return {
next: _next
};
};
const Result = (type) => {
var _result;
switch (type) {
case "stats":
_result = {
size: 0,
lines: 0,
records: 0,
errors: [],
blanks: 0,
created: undefined,
modified: undefined,
start: Date.now(),
end: undefined,
elapsed: 0
};
break;
case "distribute":
_result = {
stores: undefined,
records: 0,
errors: [],
start: Date.now(),
end: undefined,
elapsed: undefined
};
break;
case "insert":
_result = {
inserted: 0,
start: Date.now(),
end: undefined,
elapsed: 0
};
break;
case "insertFile":
_result = {
lines: 0,
inserted: 0,
errors: [],
blanks: 0,
start: Date.now(),
end: undefined
};
break;
case "select":
_result = {
lines: 0,
selected: 0,
ignored: 0,
errors: [],
blanks: 0,
start: Date.now(),
end: undefined,
elapsed: 0,
data: [],
};
break;
case "update":
_result = {
lines: 0,
selected: 0,
updated: 0,
unchanged: 0,
errors: [],
blanks: 0,
start: Date.now(),
end: undefined,
elapsed: 0,
data: [],
records: []
};
break;
case "delete":
_result = {
lines: 0,
deleted: 0,
retained: 0,
errors: [],
blanks: 0,
start: Date.now(),
end: undefined,
elapsed: 0,
data: [],
records: []
};
break;
case "aggregate":
_result = {
lines: 0,
aggregates: {},
indexed: 0,
unindexed: 0,
errors: [],
blanks: 0,
start: Date.now(),
end: undefined,
elapsed: 0
};
break;
}
return _result;
}
const Reduce = (type) => {
var _reduce;
switch (type) {
case "stats":
_reduce = Object.assign(Result("stats"), {
stores: 0,
min: undefined,
max: undefined,
mean: undefined,
var: undefined,
std: undefined,
m2: 0
});
break;
case "drop":
_reduce = {
dropped: false,
start: Date.now(),
end: 0,
elapsed: 0
};
break;
case "aggregate":
_reduce = Object.assign(Result("aggregate"), {
data: []
});
break;
default:
_reduce = Result(type);
break;
}
_reduce.details = undefined;
return _reduce;
};
const Handler = (type, ...functions) => {
var _results = Result(type);
const _next = (record, writer) => {
record = new Record(record);
_results.lines++;
if (record.length === 0) {
_results.blanks++;
} else {
if (record.data) {
switch (type) {
case "stats":
statsHandler(record, _results);
break;
case "select":
selectHandler(record, functions[0], functions[1], _results);
break;
case "update":
updateHandler(record, functions[0], functions[1], writer, _results);
break;
case "delete":
deleteHandler(record, functions[0], writer, _results);
break;
case "aggregate":
aggregateHandler(record, functions[0], functions[1], functions[2], _results);
break;
}
} else {
_results.errors.push({ error: record.error, line: _results.lines, data: record.source });
if (type === "update" || type === "delete") {
if (writer) {
writer.write(record.source + "\n");
} else {
_results.data.push(record.source);
}
}
}
}
};
const _return = () => {
_results.end = Date.now();
_results.elapsed = _results.end - _results.start;
return _results;
}
return {
next: _next,
return: _return
};
};
const statsHandler = (record, results) => {
results.records++;
return results;
};
const selectHandler = (record, selecter, projecter, results) => {
if (record.select(selecter)) {
if (projecter) {
results.data.push(record.project(projecter));
} else {
results.data.push(record.data);
}
results.selected++;
} else {
results.ignored++;
}
};
const updateHandler = (record, selecter, updater, writer, results) => {
if (record.select(selecter)) {
results.selected++;
if (record.update(updater)) {
results.updated++;
results.records.push(record.data);
} else {
results.unchanged++;
}
} else {
results.unchanged++;
}
if (writer) {
writer.write(JSON.stringify(record.data) + "\n");
} else {
results.data.push(JSON.stringify(record.data));
}
};
const deleteHandler = (record, selecter, writer, results) => {
if (record.select(selecter)) {
results.deleted++;
results.records.push(record.data);
} else {
results.retained++;
if (writer) {
writer.write(JSON.stringify(record.data) + "\n");
} else {
results.data.push(JSON.stringify(record.data));
}
}
};
const aggregateHandler = (record, selecter, indexer, projecter, results) => {
if (record.select(selecter)) {
const index = record.index(indexer);
if (!index) {
results.unindexed++;
} else {
var projection;
var fields;
if (results.aggregates[index]) {
results.aggregates[index].count++;
} else {
results.aggregates[index] = {
count: 1,
aggregates: {}
};
}
if (projecter) {
projection = record.project(projecter);
fields = Object.keys(projection);
} else {
projection = record.data;
fields = Object.keys(record.data);
}
for (const field of fields) {
if (projection[field] !== undefined) {
if (results.aggregates[index].aggregates[field]) {
accumulateAggregate(results.aggregates[index].aggregates[field], projection[field]);
} else {
results.aggregates[index].aggregates[field] = {
min: projection[field],
max: projection[field],
count: 1
};
if (typeof projection[field] === "number") {
results.aggregates[index].aggregates[field]["sum"] = projection[field];
results.aggregates[index].aggregates[field]["mean"] = projection[field];
results.aggregates[index].aggregates[field]["m2"] = 0;
}
}
}
}
results.indexed++;
}
}
}
const accumulateAggregate = (index, projection) => {
index["min"] = min(index["min"], projection);
index["max"] = max(index["max"], projection);
index["count"]++;
// Welford's algorithm
if (typeof projection === "number") {
const delta1 = projection - index["mean"];
index["sum"] += projection;
index["mean"] += delta1 / index["count"];
const delta2 = projection - index["mean"];
index["m2"] += delta1 * delta2;
}
return index;
};
class Record {
constructor(record) {
this.source = record.trim();
this.length = this.source.length
this.data = {};
this.error = "";
try {
this.data = JSON.parse(this.source)
} catch (e) {
this.data = undefined;
this.error = e.message;
}
}
}
Record.prototype.select = function (selecter) {
var result;
try {
result = selecter(this.data);
} catch {
return false;
}
if (typeof result !== "boolean") {
throw new TypeError("Selecter must return a boolean");
} else {
return result;
}
};
Record.prototype.update = function (updater) {
var result;
try {
result = updater(this.data);
} catch {
return false;
}
if (typeof result !== "object") {
throw new TypeError("Updater must return an object");
} else {
this.data = result;
return true;
}
}
Record.prototype.project = function (projecter) {
var result;
try {
result = projecter(this.data);
} catch {
return undefined;
}
if (Array.isArray(result) || typeof result !== "object") {
throw new TypeError("Projecter must return an object");
} else {
return result;
}
};
Record.prototype.index = function (indexer) {
try {
return indexer(this.data);
} catch {
return undefined;
}
};
const Reducer = (type, results) => {
var _reduce = Reduce(type);
var i = 0;
var aggregates = {};
for (const result of results) {
switch (type) {
case "stats":
statsReducer(_reduce, result, i);
break;
case "insert":
insertReducer(_reduce, result);
break;
case "select":
selectReducer(_reduce, result);
break;
case "update":
updateReducer(_reduce, result);
break;
case "delete":
deleteReducer(_reduce, result);
break;
case "aggregate":
aggregateReducer(_reduce, result, aggregates);
break
}
if (type === "stats") {
_reduce.stores++;
i++;
}
if (type === "drop") {
_reduce.dropped = true;
} else if (type !== "insert") {
_reduce.lines += result.lines;
_reduce.errors = _reduce.errors.concat(result.errors);
_reduce.blanks += result.blanks;
}
_reduce.start = min(_reduce.start, result.start);
_reduce.end = max(_reduce.end, result.end);
}
if (type === "stats") {
_reduce.size = convertSize(_reduce.size);
_reduce.var = _reduce.m2 / (results.length);
_reduce.std = Math.sqrt(_reduce.m2 / (results.length));
delete _reduce.m2;
} else if (type === "aggregate") {
for (const index of Object.keys(aggregates)) {
var aggregate = {
index: index,
count: aggregates[index].count,
aggregates: []
};
for (const field of Object.keys(aggregates[index].aggregates)) {
delete aggregates[index].aggregates[field].m2;
aggregate.aggregates.push({ field: field, data: aggregates[index].aggregates[field] });
}
_reduce.data.push(aggregate);
}
delete _reduce.aggregates;
}
_reduce.elapsed = _reduce.end - _reduce.start;
_reduce.details = results;
return _reduce;
};
const statsReducer = (reduce, result, i) => {
reduce.size += result.size;
reduce.records += result.records;
reduce.min = min(reduce.min, result.records);
reduce.max = max(reduce.max, result.records);
if (reduce.mean === undefined) reduce.mean = result.records;
const delta1 = result.records - reduce.mean;
reduce.mean += delta1 / (i + 2);
const delta2 = result.records - reduce.mean;
reduce.m2 += delta1 * delta2;
reduce.created = min(reduce.created, result.created);
reduce.modified = max(reduce.modified, result.modified);
};
const insertReducer = (reduce, result) => {
reduce.inserted += result.inserted;
};
const selectReducer = (reduce, result) => {
reduce.selected += result.selected;
reduce.ignored += result.ignored;
reduce.data = reduce.data.concat(result.data);
delete result.data;
};
const updateReducer = (reduce, result) => {
reduce.selected += result.selected;
reduce.updated += result.updated;
reduce.unchanged += result.unchanged;
};
const deleteReducer = (reduce, result) => {
reduce.deleted += result.deleted;
reduce.retained += result.retained;
};
const aggregateReducer = (reduce, result, aggregates) => {
reduce.indexed += result.indexed;
reduce.unindexed += result.unindexed;
const indexes = Object.keys(result.aggregates);
for (const index of indexes) {
if (aggregates[index]) {
aggregates[index].count += result.aggregates[index].count;
} else {
aggregates[index] = {
count: result.aggregates[index].count,
aggregates: {}
};
}
const fields = Object.keys(result.aggregates[index].aggregates);
for (const field of fields) {
const aggregateObject = aggregates[index].aggregates[field];
const resultObject = result.aggregates[index].aggregates[field];
if (aggregateObject) {
reduceAggregate(aggregateObject, resultObject);
} else {
aggregates[index].aggregates[field] = {
min: resultObject["min"],
max: resultObject["max"],
count: resultObject["count"]
};
if (resultObject["m2"] !== undefined) {
aggregates[index].aggregates[field]["sum"] = resultObject["sum"];
aggregates[index].aggregates[field]["mean"] = resultObject["mean"];
aggregates[index].aggregates[field]["varp"] = resultObject["m2"] / resultObject["count"];
aggregates[index].aggregates[field]["vars"] = resultObject["m2"] / (resultObject["count"] - 1);
aggregates[index].aggregates[field]["stdp"] = Math.sqrt(resultObject["m2"] / resultObject["count"]);
aggregates[index].aggregates[field]["stds"] = Math.sqrt(resultObject["m2"] / (resultObject["count"] - 1));
aggregates[index].aggregates[field]["m2"] = resultObject["m2"];
}
}
}
}
delete result.aggregates;
};
const reduceAggregate = (aggregate, result) => {
const n = aggregate["count"] + result["count"];
aggregate["min"] = min(aggregate["min"], result["min"]);
aggregate["max"] = max(aggregate["max"], result["max"]);
// Parallel version of Welford's algorithm
if (result["m2"] !== undefined) {
const delta = result["mean"] - aggregate["mean"];
const m2 = aggregate["m2"] + result["m2"] + (Math.pow(delta, 2) * ((aggregate["count"] * result["count"]) / n));
aggregate["m2"] = m2;
aggregate["varp"] = m2 / n;
aggregate["vars"] = m2 / (n - 1);
aggregate["stdp"] = Math.sqrt(m2 / n);
aggregate["stds"] = Math.sqrt(m2 / (n - 1));
}
if (result["sum"] !== undefined) {
aggregate["mean"] = (aggregate["sum"] + result["sum"]) / n;
aggregate["sum"] += result["sum"];
}
aggregate["count"] = n;
};
exports.Randomizer = Randomizer;
exports.Result = Result;
exports.Reduce = Reduce;
exports.Handler = Handler;
exports.Reducer = Reducer;

View file

@ -1,178 +0,0 @@
"use strict";
const {
access,
constants,
existsSync,
rename,
renameSync,
rmdir,
rmdirSync,
unlink,
unlinkSync
} = require("graceful-fs");
const { promisify } = require("util");
const min = (a, b) => {
if (b === undefined || a <= b) return a;
return b;
};
const max = (a, b) => {
if (b === undefined || a > b) return a;
return b;
};
const convertSize = (size) => {
const sizes = ["bytes", "KB", "MB", "GB"];
var index = Math.floor(Math.log2(size) / 10);
if (index > 3) index = 3;
return Math.round(((size / Math.pow(1024, index)) + Number.EPSILON) * 100) / 100 + " " + sizes[index];
};
const fileExists = async (a) => {
try {
await promisify(access)(a, constants.F_OK);
return true;
} catch (error) {
// console.error(error); file does not exist no need for error
return false;
}
}
const fileExistsSync = (a) => {
try {
return existsSync(a);
} catch (error) {
console.error(error);
return false;
}
}
const moveFile = async (a, b) => {
try {
await promisify(rename)(a, b);
return true;
} catch (error) {
console.error(error);
return false;
}
};
const moveFileSync = (a, b) => {
try {
renameSync(a, b);
return true;
} catch (error) {
console.error(error);
return false;
}
};
const deleteFile = async (filepath) => {
try {
await promisify(unlink)(filepath);
return true;
} catch (error) {
console.error(error);
return false;
}
};
const deleteFileSync = (filepath) => {
try {
unlinkSync(filepath);
return true;
} catch (error) {
console.error(error);
return false;
}
}
const replaceFile = async (a, b) => {
if (!await moveFile(a, a + ".old")) return false;
if (!await moveFile(b, a)) {
await moveFile(a + ".old", a);
return false;
}
await deleteFile(a + ".old");
return true;
};
const replaceFileSync = (a, b) => {
if (!moveFileSync(a, a + ".old")) return false;
if (!moveFileSync(b, a)) {
moveFile(a + ".old", a);
return false;
}
deleteFileSync(a + ".old");
return true;
};
const deleteDirectory = async (dirpath) => {
try {
await promisify(rmdir)(dirpath);
return true;
} catch {
return false;
}
};
const deleteDirectorySync = (dirpath) => {
try {
rmdirSync(dirpath);
return true;
} catch {
return false;
}
};
const releaseLock = async (store, release) => {
try {
await release();
} catch (error) {
if (!["ERELEASED", "ENOTACQUIRED"].includes(error.code)) {
error.store = store;
throw error;
}
}
}
const releaseLockSync = (store, release) => {
try {
release();
} catch (error) {
if (!["ERELEASED", "ENOTACQUIRED"].includes(error.code)) {
error.store = store;
throw error;
}
}
}
exports.min = min;
exports.max = max;
exports.convertSize = convertSize;
exports.fileExists = fileExists;
exports.fileExistsSync = fileExistsSync;
exports.moveFile = moveFile;
exports.moveFileSync = moveFileSync;
exports.replaceFile = replaceFile;
exports.replaceFileSync = replaceFileSync;
exports.deleteFile = deleteFile;
exports.deleteFileSync = deleteFileSync;
exports.deleteDirectory = deleteDirectory;
exports.deleteDirectorySync = deleteDirectorySync;
exports.releaseLock = releaseLock;
exports.releaseLockSync = releaseLockSync;

View file

@ -1,70 +0,0 @@
"use strict";
const { existsSync } = require("graceful-fs");
const validateSize = (s) => {
if (typeof s !== "number") {
throw new TypeError("Size must be a number");
} else if (s <= 0) {
throw new RangeError("Size must be greater than zero");
}
return s;
};
const validateName = (n) => {
if (typeof n !== "string") {
throw new TypeError("Name must be a string");
} else if (n.trim().length <= 0) {
throw new Error("Name must be a non-blank string")
}
return n;
};
const validatePath = (p) => {
if (typeof p !== "string") {
throw new TypeError("Path must be a string");
} else if (p.trim().length <= 0) {
throw new Error("Path must be a non-blank string");
} else if (!existsSync(p)) {
throw new Error("Path does not exist");
}
return p;
};
const validateArray = (a) => {
if (!Array.isArray(a)) {
throw new TypeError("Not an array");
}
return a;
};
const validateObject = (o) => {
if (typeof o !== "object") {
throw new TypeError("Not an object");
}
return o;
};
const validateFunction = (f) => {
if (typeof f !== "function") {
throw new TypeError("Not a function")
}
// } else {
// const fString = f.toString();
// if (/\s*function/.test(fString) && !/\W+return\W+/.test(fString)) throw new Error("Function must return a value");
// }
return f;
}
exports.validateSize = validateSize;
exports.validateName = validateName;
exports.validatePath = validatePath;
exports.validateArray = validateArray;
exports.validateObject = validateObject;
exports.validateFunction = validateFunction;

View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,46 +0,0 @@
'use strict';
//
// used by njodb
// Source: https://github.com/moxystudio/node-proper-lockfile
//
const lockfile = require('./lib/lockfile');
const { toPromise, toSync, toSyncOptions } = require('./lib/adapter');
async function lock(file, options) {
const release = await toPromise(lockfile.lock)(file, options);
return toPromise(release);
}
function lockSync(file, options) {
const release = toSync(lockfile.lock)(file, toSyncOptions(options));
return toSync(release);
}
function unlock(file, options) {
return toPromise(lockfile.unlock)(file, options);
}
function unlockSync(file, options) {
return toSync(lockfile.unlock)(file, toSyncOptions(options));
}
function check(file, options) {
return toPromise(lockfile.check)(file, options);
}
function checkSync(file, options) {
return toSync(lockfile.check)(file, toSyncOptions(options));
}
module.exports = lock;
module.exports.lock = lock;
module.exports.unlock = unlock;
module.exports.lockSync = lockSync;
module.exports.unlockSync = unlockSync;
module.exports.check = check;
module.exports.checkSync = checkSync;

View file

@ -1,85 +0,0 @@
'use strict';
const fs = require('graceful-fs');
function createSyncFs(fs) {
const methods = ['mkdir', 'realpath', 'stat', 'rmdir', 'utimes'];
const newFs = { ...fs };
methods.forEach((method) => {
newFs[method] = (...args) => {
const callback = args.pop();
let ret;
try {
ret = fs[`${method}Sync`](...args);
} catch (err) {
return callback(err);
}
callback(null, ret);
};
});
return newFs;
}
// ----------------------------------------------------------
function toPromise(method) {
return (...args) => new Promise((resolve, reject) => {
args.push((err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
method(...args);
});
}
function toSync(method) {
return (...args) => {
let err;
let result;
args.push((_err, _result) => {
err = _err;
result = _result;
});
method(...args);
if (err) {
throw err;
}
return result;
};
}
function toSyncOptions(options) {
// Shallow clone options because we are oging to mutate them
options = { ...options };
// Transform fs to use the sync methods instead
options.fs = createSyncFs(options.fs || fs);
// Retries are not allowed because it requires the flow to be sync
if (
(typeof options.retries === 'number' && options.retries > 0) ||
(options.retries && typeof options.retries.retries === 'number' && options.retries.retries > 0)
) {
throw Object.assign(new Error('Cannot use retries with the sync api'), { code: 'ESYNC' });
}
return options;
}
module.exports = {
toPromise,
toSync,
toSyncOptions,
};

View file

@ -1,345 +0,0 @@
'use strict';
const path = require('path');
const fs = require('graceful-fs');
const retry = require('../../retry');
const onExit = require('../../signalExit');
const mtimePrecision = require('./mtime-precision');
const locks = {};
function getLockFile(file, options) {
return options.lockfilePath || `${file}.lock`;
}
function resolveCanonicalPath(file, options, callback) {
if (!options.realpath) {
return callback(null, path.resolve(file));
}
// Use realpath to resolve symlinks
// It also resolves relative paths
options.fs.realpath(file, callback);
}
function acquireLock(file, options, callback) {
const lockfilePath = getLockFile(file, options);
// Use mkdir to create the lockfile (atomic operation)
options.fs.mkdir(lockfilePath, (err) => {
if (!err) {
// At this point, we acquired the lock!
// Probe the mtime precision
return mtimePrecision.probe(lockfilePath, options.fs, (err, mtime, mtimePrecision) => {
// If it failed, try to remove the lock..
/* istanbul ignore if */
if (err) {
options.fs.rmdir(lockfilePath, () => { });
return callback(err);
}
callback(null, mtime, mtimePrecision);
});
}
// If error is not EEXIST then some other error occurred while locking
if (err.code !== 'EEXIST') {
return callback(err);
}
// Otherwise, check if lock is stale by analyzing the file mtime
if (options.stale <= 0) {
return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file }));
}
options.fs.stat(lockfilePath, (err, stat) => {
if (err) {
// Retry if the lockfile has been removed (meanwhile)
// Skip stale check to avoid recursiveness
if (err.code === 'ENOENT') {
return acquireLock(file, { ...options, stale: 0 }, callback);
}
return callback(err);
}
if (!isLockStale(stat, options)) {
return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file }));
}
// If it's stale, remove it and try again!
// Skip stale check to avoid recursiveness
removeLock(file, options, (err) => {
if (err) {
return callback(err);
}
acquireLock(file, { ...options, stale: 0 }, callback);
});
});
});
}
function isLockStale(stat, options) {
return stat.mtime.getTime() < Date.now() - options.stale;
}
function removeLock(file, options, callback) {
// Remove lockfile, ignoring ENOENT errors
options.fs.rmdir(getLockFile(file, options), (err) => {
if (err && err.code !== 'ENOENT') {
return callback(err);
}
callback();
});
}
function updateLock(file, options) {
const lock = locks[file];
// Just for safety, should never happen
/* istanbul ignore if */
if (lock.updateTimeout) {
return;
}
lock.updateDelay = lock.updateDelay || options.update;
lock.updateTimeout = setTimeout(() => {
lock.updateTimeout = null;
// Stat the file to check if mtime is still ours
// If it is, we can still recover from a system sleep or a busy event loop
options.fs.stat(lock.lockfilePath, (err, stat) => {
const isOverThreshold = lock.lastUpdate + options.stale < Date.now();
// If it failed to update the lockfile, keep trying unless
// the lockfile was deleted or we are over the threshold
if (err) {
if (err.code === 'ENOENT' || isOverThreshold) {
console.error(`lockfile "${file}" compromised. stat code=${err.code}, isOverThreshold=${isOverThreshold}`)
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
}
lock.updateDelay = 1000;
return updateLock(file, options);
}
const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
if (!isMtimeOurs) {
console.error(`lockfile "${file}" compromised. mtime is not ours`)
return setLockAsCompromised(
file,
lock,
Object.assign(
new Error('Unable to update lock within the stale threshold'),
{ code: 'ECOMPROMISED' }
));
}
const mtime = mtimePrecision.getMtime(lock.mtimePrecision);
options.fs.utimes(lock.lockfilePath, mtime, mtime, (err) => {
const isOverThreshold = lock.lastUpdate + options.stale < Date.now();
// Ignore if the lock was released
if (lock.released) {
return;
}
// If it failed to update the lockfile, keep trying unless
// the lockfile was deleted or we are over the threshold
if (err) {
if (err.code === 'ENOENT' || isOverThreshold) {
console.error(`lockfile "${file}" compromised. utimes code=${err.code}, isOverThreshold=${isOverThreshold}`)
return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
}
lock.updateDelay = 1000;
return updateLock(file, options);
}
// All ok, keep updating..
lock.mtime = mtime;
lock.lastUpdate = Date.now();
lock.updateDelay = null;
updateLock(file, options);
});
});
}, lock.updateDelay);
// Unref the timer so that the nodejs process can exit freely
// This is safe because all acquired locks will be automatically released
// on process exit
// We first check that `lock.updateTimeout.unref` exists because some users
// may be using this module outside of NodeJS (e.g., in an electron app),
// and in those cases `setTimeout` return an integer.
/* istanbul ignore else */
if (lock.updateTimeout.unref) {
lock.updateTimeout.unref();
}
}
function setLockAsCompromised(file, lock, err) {
// Signal the lock has been released
lock.released = true;
// Cancel lock mtime update
// Just for safety, at this point updateTimeout should be null
/* istanbul ignore if */
if (lock.updateTimeout) {
clearTimeout(lock.updateTimeout);
}
if (locks[file] === lock) {
delete locks[file];
}
lock.options.onCompromised(err);
}
// ----------------------------------------------------------
function lock(file, options, callback) {
/* istanbul ignore next */
options = {
stale: 10000,
update: null,
realpath: true,
retries: 0,
fs,
onCompromised: (err) => { throw err; },
...options,
};
options.retries = options.retries || 0;
options.retries = typeof options.retries === 'number' ? { retries: options.retries } : options.retries;
options.stale = Math.max(options.stale || 0, 2000);
options.update = options.update == null ? options.stale / 2 : options.update || 0;
options.update = Math.max(Math.min(options.update, options.stale / 2), 1000);
// Resolve to a canonical file path
resolveCanonicalPath(file, options, (err, file) => {
if (err) {
return callback(err);
}
// Attempt to acquire the lock
const operation = retry.operation(options.retries);
operation.attempt(() => {
acquireLock(file, options, (err, mtime, mtimePrecision) => {
if (operation.retry(err)) {
return;
}
if (err) {
return callback(operation.mainError());
}
// We now own the lock
const lock = locks[file] = {
lockfilePath: getLockFile(file, options),
mtime,
mtimePrecision,
options,
lastUpdate: Date.now(),
};
// We must keep the lock fresh to avoid staleness
updateLock(file, options);
callback(null, (releasedCallback) => {
if (lock.released) {
return releasedCallback &&
releasedCallback(Object.assign(new Error('Lock is already released'), { code: 'ERELEASED' }));
}
// Not necessary to use realpath twice when unlocking
unlock(file, { ...options, realpath: false }, releasedCallback);
});
});
});
});
}
function unlock(file, options, callback) {
options = {
fs,
realpath: true,
...options,
};
// Resolve to a canonical file path
resolveCanonicalPath(file, options, (err, file) => {
if (err) {
return callback(err);
}
// Skip if the lock is not acquired
const lock = locks[file];
if (!lock) {
return callback(Object.assign(new Error('Lock is not acquired/owned by you'), { code: 'ENOTACQUIRED' }));
}
lock.updateTimeout && clearTimeout(lock.updateTimeout); // Cancel lock mtime update
lock.released = true; // Signal the lock has been released
delete locks[file]; // Delete from locks
removeLock(file, options, callback);
});
}
function check(file, options, callback) {
options = {
stale: 10000,
realpath: true,
fs,
...options,
};
options.stale = Math.max(options.stale || 0, 2000);
// Resolve to a canonical file path
resolveCanonicalPath(file, options, (err, file) => {
if (err) {
return callback(err);
}
// Check if lockfile exists
options.fs.stat(getLockFile(file, options), (err, stat) => {
if (err) {
// If does not exist, file is not locked. Otherwise, callback with error
return err.code === 'ENOENT' ? callback(null, false) : callback(err);
}
// Otherwise, check if lock is stale by analyzing the file mtime
return callback(null, !isLockStale(stat, options));
});
});
}
function getLocks() {
return locks;
}
// Remove acquired locks on exit
/* istanbul ignore next */
onExit(() => {
for (const file in locks) {
const options = locks[file].options;
try { options.fs.rmdirSync(getLockFile(file, options)); } catch (e) { /* Empty */ }
}
});
module.exports.lock = lock;
module.exports.unlock = unlock;
module.exports.check = check;
module.exports.getLocks = getLocks;

View file

@ -1,55 +0,0 @@
'use strict';
const cacheSymbol = Symbol();
function probe(file, fs, callback) {
const cachedPrecision = fs[cacheSymbol];
if (cachedPrecision) {
return fs.stat(file, (err, stat) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
callback(null, stat.mtime, cachedPrecision);
});
}
// Set mtime by ceiling Date.now() to seconds + 5ms so that it's "not on the second"
const mtime = new Date((Math.ceil(Date.now() / 1000) * 1000) + 5);
fs.utimes(file, mtime, mtime, (err) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
fs.stat(file, (err, stat) => {
/* istanbul ignore if */
if (err) {
return callback(err);
}
const precision = stat.mtime.getTime() % 1000 === 0 ? 's' : 'ms';
// Cache the precision in a non-enumerable way
Object.defineProperty(fs, cacheSymbol, { value: precision });
callback(null, stat.mtime, precision);
});
});
}
function getMtime(precision) {
let now = Date.now();
if (precision === 's') {
now = Math.ceil(now / 1000) * 1000;
}
return new Date(now);
}
module.exports.probe = probe;
module.exports.getMtime = getMtime;

View file

@ -1,21 +0,0 @@
Copyright (c) 2011:
Tim Koschützki (tim@debuggable.com)
Felix Geisendörfer (felix@debuggable.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,105 +0,0 @@
//
// used by properLockFile
// Source: https://github.com/tim-kos/node-retry
//
var RetryOperation = require('./retry_operation');
exports.operation = function (options) {
var timeouts = exports.timeouts(options);
return new RetryOperation(timeouts, {
forever: options && options.forever,
unref: options && options.unref,
maxRetryTime: options && options.maxRetryTime
});
};
exports.timeouts = function (options) {
if (options instanceof Array) {
return [].concat(options);
}
var opts = {
retries: 10,
factor: 2,
minTimeout: 1 * 1000,
maxTimeout: Infinity,
randomize: false
};
for (var key in options) {
opts[key] = options[key];
}
if (opts.minTimeout > opts.maxTimeout) {
throw new Error('minTimeout is greater than maxTimeout');
}
var timeouts = [];
for (var i = 0; i < opts.retries; i++) {
timeouts.push(this.createTimeout(i, opts));
}
if (options && options.forever && !timeouts.length) {
timeouts.push(this.createTimeout(i, opts));
}
// sort the array numerically ascending
timeouts.sort(function (a, b) {
return a - b;
});
return timeouts;
};
exports.createTimeout = function (attempt, opts) {
var random = (opts.randomize)
? (Math.random() + 1)
: 1;
var timeout = Math.round(random * opts.minTimeout * Math.pow(opts.factor, attempt));
timeout = Math.min(timeout, opts.maxTimeout);
return timeout;
};
exports.wrap = function (obj, options, methods) {
if (options instanceof Array) {
methods = options;
options = null;
}
if (!methods) {
methods = [];
for (var key in obj) {
if (typeof obj[key] === 'function') {
methods.push(key);
}
}
}
for (var i = 0; i < methods.length; i++) {
var method = methods[i];
var original = obj[method];
obj[method] = function retryWrapper(original) {
var op = exports.operation(options);
var args = Array.prototype.slice.call(arguments, 1);
var callback = args.pop();
args.push(function (err) {
if (op.retry(err)) {
return;
}
if (err) {
arguments[0] = op.mainError();
}
callback.apply(this, arguments);
});
op.attempt(function () {
original.apply(obj, args);
});
}.bind(obj, original);
obj[method].options = options;
}
};

View file

@ -1,158 +0,0 @@
function RetryOperation(timeouts, options) {
// Compatibility for the old (timeouts, retryForever) signature
if (typeof options === 'boolean') {
options = { forever: options };
}
this._originalTimeouts = JSON.parse(JSON.stringify(timeouts));
this._timeouts = timeouts;
this._options = options || {};
this._maxRetryTime = options && options.maxRetryTime || Infinity;
this._fn = null;
this._errors = [];
this._attempts = 1;
this._operationTimeout = null;
this._operationTimeoutCb = null;
this._timeout = null;
this._operationStart = null;
if (this._options.forever) {
this._cachedTimeouts = this._timeouts.slice(0);
}
}
module.exports = RetryOperation;
RetryOperation.prototype.reset = function() {
this._attempts = 1;
this._timeouts = this._originalTimeouts;
}
RetryOperation.prototype.stop = function() {
if (this._timeout) {
clearTimeout(this._timeout);
}
this._timeouts = [];
this._cachedTimeouts = null;
};
RetryOperation.prototype.retry = function(err) {
if (this._timeout) {
clearTimeout(this._timeout);
}
if (!err) {
return false;
}
var currentTime = new Date().getTime();
if (err && currentTime - this._operationStart >= this._maxRetryTime) {
this._errors.unshift(new Error('RetryOperation timeout occurred'));
return false;
}
this._errors.push(err);
var timeout = this._timeouts.shift();
if (timeout === undefined) {
if (this._cachedTimeouts) {
// retry forever, only keep last error
this._errors.splice(this._errors.length - 1, this._errors.length);
this._timeouts = this._cachedTimeouts.slice(0);
timeout = this._timeouts.shift();
} else {
return false;
}
}
var self = this;
var timer = setTimeout(function() {
self._attempts++;
if (self._operationTimeoutCb) {
self._timeout = setTimeout(function() {
self._operationTimeoutCb(self._attempts);
}, self._operationTimeout);
if (self._options.unref) {
self._timeout.unref();
}
}
self._fn(self._attempts);
}, timeout);
if (this._options.unref) {
timer.unref();
}
return true;
};
RetryOperation.prototype.attempt = function(fn, timeoutOps) {
this._fn = fn;
if (timeoutOps) {
if (timeoutOps.timeout) {
this._operationTimeout = timeoutOps.timeout;
}
if (timeoutOps.cb) {
this._operationTimeoutCb = timeoutOps.cb;
}
}
var self = this;
if (this._operationTimeoutCb) {
this._timeout = setTimeout(function() {
self._operationTimeoutCb();
}, self._operationTimeout);
}
this._operationStart = new Date().getTime();
this._fn(this._attempts);
};
RetryOperation.prototype.try = function(fn) {
console.log('Using RetryOperation.try() is deprecated');
this.attempt(fn);
};
RetryOperation.prototype.start = function(fn) {
console.log('Using RetryOperation.start() is deprecated');
this.attempt(fn);
};
RetryOperation.prototype.start = RetryOperation.prototype.try;
RetryOperation.prototype.errors = function() {
return this._errors;
};
RetryOperation.prototype.attempts = function() {
return this._attempts;
};
RetryOperation.prototype.mainError = function() {
if (this._errors.length === 0) {
return null;
}
var counts = {};
var mainError = null;
var mainErrorCount = 0;
for (var i = 0; i < this._errors.length; i++) {
var error = this._errors[i];
var message = error.message;
var count = (counts[message] || 0) + 1;
counts[message] = count;
if (count >= mainErrorCount) {
mainError = error;
mainErrorCount = count;
}
}
return mainError;
};

View file

@ -1,16 +0,0 @@
The ISC License
Copyright (c) 2015-2022 Benjamin Coe, Isaac Z. Schlueter, and Contributors
Permission to use, copy, modify, and/or distribute this software
for any purpose with or without fee is hereby granted, provided
that the above copyright notice and this permission notice
appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE
LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View file

@ -1,207 +0,0 @@
//
// used by properLockFile
// Source: https://github.com/tapjs/signal-exit
//
// Note: since nyc uses this module to output coverage, any lines
// that are in the direct sync flow of nyc's outputCoverage are
// ignored, since we can never get coverage for them.
// grab a reference to node's real process object right away
var process = global.process
const processOk = function (process) {
return process &&
typeof process === 'object' &&
typeof process.removeListener === 'function' &&
typeof process.emit === 'function' &&
typeof process.reallyExit === 'function' &&
typeof process.listeners === 'function' &&
typeof process.kill === 'function' &&
typeof process.pid === 'number' &&
typeof process.on === 'function'
}
// some kind of non-node environment, just no-op
/* istanbul ignore if */
if (!processOk(process)) {
module.exports = function () {
return function () { }
}
} else {
var assert = require('assert')
var signals = require('./signals.js')
var isWin = /^win/i.test(process.platform)
var EE = require('events')
/* istanbul ignore if */
if (typeof EE !== 'function') {
EE = EE.EventEmitter
}
var emitter
if (process.__signal_exit_emitter__) {
emitter = process.__signal_exit_emitter__
} else {
emitter = process.__signal_exit_emitter__ = new EE()
emitter.count = 0
emitter.emitted = {}
}
// Because this emitter is a global, we have to check to see if a
// previous version of this library failed to enable infinite listeners.
// I know what you're about to say. But literally everything about
// signal-exit is a compromise with evil. Get used to it.
if (!emitter.infinite) {
emitter.setMaxListeners(Infinity)
emitter.infinite = true
}
module.exports = function (cb, opts) {
/* istanbul ignore if */
if (!processOk(global.process)) {
return function () { }
}
assert.equal(typeof cb, 'function', 'a callback must be provided for exit handler')
if (loaded === false) {
load()
}
var ev = 'exit'
if (opts && opts.alwaysLast) {
ev = 'afterexit'
}
var remove = function () {
emitter.removeListener(ev, cb)
if (emitter.listeners('exit').length === 0 &&
emitter.listeners('afterexit').length === 0) {
unload()
}
}
emitter.on(ev, cb)
return remove
}
var unload = function unload() {
if (!loaded || !processOk(global.process)) {
return
}
loaded = false
signals.forEach(function (sig) {
try {
process.removeListener(sig, sigListeners[sig])
} catch (er) { }
})
process.emit = originalProcessEmit
process.reallyExit = originalProcessReallyExit
emitter.count -= 1
}
module.exports.unload = unload
var emit = function emit(event, code, signal) {
/* istanbul ignore if */
if (emitter.emitted[event]) {
return
}
emitter.emitted[event] = true
emitter.emit(event, code, signal)
}
// { <signal>: <listener fn>, ... }
var sigListeners = {}
signals.forEach(function (sig) {
sigListeners[sig] = function listener() {
/* istanbul ignore if */
if (!processOk(global.process)) {
return
}
// If there are no other listeners, an exit is coming!
// Simplest way: remove us and then re-send the signal.
// We know that this will kill the process, so we can
// safely emit now.
var listeners = process.listeners(sig)
if (listeners.length === emitter.count) {
unload()
emit('exit', null, sig)
/* istanbul ignore next */
emit('afterexit', null, sig)
/* istanbul ignore next */
if (isWin && sig === 'SIGHUP') {
// "SIGHUP" throws an `ENOSYS` error on Windows,
// so use a supported signal instead
sig = 'SIGINT'
}
/* istanbul ignore next */
process.kill(process.pid, sig)
}
}
})
module.exports.signals = function () {
return signals
}
var loaded = false
var load = function load() {
if (loaded || !processOk(global.process)) {
return
}
loaded = true
// This is the number of onSignalExit's that are in play.
// It's important so that we can count the correct number of
// listeners on signals, and don't wait for the other one to
// handle it instead of us.
emitter.count += 1
signals = signals.filter(function (sig) {
try {
process.on(sig, sigListeners[sig])
return true
} catch (er) {
return false
}
})
process.emit = processEmit
process.reallyExit = processReallyExit
}
module.exports.load = load
var originalProcessReallyExit = process.reallyExit
var processReallyExit = function processReallyExit(code) {
/* istanbul ignore if */
if (!processOk(global.process)) {
return
}
process.exitCode = code || /* istanbul ignore next */ 0
emit('exit', process.exitCode, null)
/* istanbul ignore next */
emit('afterexit', process.exitCode, null)
/* istanbul ignore next */
originalProcessReallyExit.call(process, process.exitCode)
}
var originalProcessEmit = process.emit
var processEmit = function processEmit(ev, arg) {
if (ev === 'exit' && processOk(global.process)) {
/* istanbul ignore else */
if (arg !== undefined) {
process.exitCode = arg
}
var ret = originalProcessEmit.apply(this, arguments)
/* istanbul ignore next */
emit('exit', process.exitCode, null)
/* istanbul ignore next */
emit('afterexit', process.exitCode, null)
/* istanbul ignore next */
return ret
} else {
return originalProcessEmit.apply(this, arguments)
}
}
}

View file

@ -1,53 +0,0 @@
// This is not the set of all possible signals.
//
// It IS, however, the set of all signals that trigger
// an exit on either Linux or BSD systems. Linux is a
// superset of the signal names supported on BSD, and
// the unknown signals just fail to register, so we can
// catch that easily enough.
//
// Don't bother with SIGKILL. It's uncatchable, which
// means that we can't fire any callbacks anyway.
//
// If a user does happen to register a handler on a non-
// fatal signal like SIGWINCH or something, and then
// exit, it'll end up firing `process.emit('exit')`, so
// the handler will be fired anyway.
//
// SIGBUS, SIGFPE, SIGSEGV and SIGILL, when not raised
// artificially, inherently leave the process in a
// state from which it is not safe to try and enter JS
// listeners.
module.exports = [
'SIGABRT',
'SIGALRM',
'SIGHUP',
'SIGINT',
'SIGTERM'
]
if (process.platform !== 'win32') {
module.exports.push(
'SIGVTALRM',
'SIGXCPU',
'SIGXFSZ',
'SIGUSR2',
'SIGTRAP',
'SIGSYS',
'SIGQUIT',
'SIGIOT'
// should detect profiler and enable/disable accordingly.
// see #21
// 'SIGPROF'
)
}
if (process.platform === 'linux') {
module.exports.push(
'SIGIO',
'SIGPOLL',
'SIGPWR',
'SIGSTKFLT',
'SIGUNUSED'
)
}

View file

@ -10,8 +10,7 @@ const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
class AbMergeManager {
constructor(db, taskManager) {
this.db = db
constructor(taskManager) {
this.taskManager = taskManager
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
@ -46,7 +45,7 @@ class AbMergeManager {
toneJsonObject: null
}
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData)
this.taskManager.addTask(task)
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
@ -58,7 +57,7 @@ class AbMergeManager {
}
async runAudiobookMerge(libraryItem, task, encodingOptions) {
const audioBitrate = encodingOptions.bitrate || '64k'
const audioBitrate = encodingOptions.bitrate || '128k'
const audioCodec = encodingOptions.codec || 'aac'
const audioChannels = encodingOptions.channels || 2

View file

@ -10,8 +10,7 @@ const toneHelpers = require('../utils/toneHelpers')
const Task = require('../objects/Task')
class AudioMetadataMangaer {
constructor(db, taskManager) {
this.db = db
constructor(taskManager) {
this.taskManager = taskManager
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
@ -86,7 +85,7 @@ class AudioMetadataMangaer {
}
}
const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, taskData)
task.setData('embed-metadata', 'Embedding Metadata', taskDescription, false, taskData)
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`)

View file

@ -1,40 +1,47 @@
const sqlite3 = require('sqlite3')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const cron = require('../libs/nodeCron')
const fs = require('../libs/fsExtra')
const archiver = require('../libs/archiver')
const StreamZip = require('../libs/nodeStreamZip')
const fileUtils = require('../utils/fileUtils')
// Utils
const { getFileSize } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const Backup = require('../objects/Backup')
class BackupManager {
constructor(db) {
constructor() {
this.BackupPath = Path.join(global.MetadataPath, 'backups')
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
this.db = db
this.scheduleTask = null
this.backups = []
}
get serverSettings() {
return this.db.serverSettings || {}
get backupSchedule() {
return global.ServerSettings.backupSchedule
}
get backupsToKeep() {
return global.ServerSettings.backupsToKeep || 2
}
get maxBackupSize() {
return global.ServerSettings.maxBackupSize || 1
}
async init() {
var backupsDirExists = await fs.pathExists(this.BackupPath)
const backupsDirExists = await fs.pathExists(this.BackupPath)
if (!backupsDirExists) {
await fs.ensureDir(this.BackupPath)
await filePerms.setDefault(this.BackupPath)
}
await this.loadBackups()
@ -42,42 +49,42 @@ class BackupManager {
}
scheduleCron() {
if (!this.serverSettings.backupSchedule) {
if (!this.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)
return
}
try {
var cronSchedule = this.serverSettings.backupSchedule
var cronSchedule = this.backupSchedule
this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))
} catch (error) {
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`, error)
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.backupSchedule}`, error)
}
}
updateCronSchedule() {
if (this.scheduleTask && !this.serverSettings.backupSchedule) {
if (this.scheduleTask && !this.backupSchedule) {
Logger.info(`[BackupManager] Disabling backup schedule`)
if (this.scheduleTask.stop) this.scheduleTask.stop()
this.scheduleTask = null
} else if (!this.scheduleTask && this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Starting backup schedule ${this.serverSettings.backupSchedule}`)
} else if (!this.scheduleTask && this.backupSchedule) {
Logger.info(`[BackupManager] Starting backup schedule ${this.backupSchedule}`)
this.scheduleCron()
} else if (this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Restarting backup schedule ${this.serverSettings.backupSchedule}`)
} else if (this.backupSchedule) {
Logger.info(`[BackupManager] Restarting backup schedule ${this.backupSchedule}`)
if (this.scheduleTask.stop) this.scheduleTask.stop()
this.scheduleCron()
}
}
async uploadBackup(req, res) {
var backupFile = req.files.file
const backupFile = req.files.file
if (Path.extname(backupFile.name) !== '.audiobookshelf') {
Logger.error(`[BackupManager] Invalid backup file uploaded "${backupFile.name}"`)
return res.status(500).send('Invalid backup file')
}
var tempPath = Path.join(this.BackupPath, backupFile.name)
var success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
const tempPath = Path.join(this.BackupPath, fileUtils.sanitizeFilename(backupFile.name))
const success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
Logger.error('[BackupManager] Failed to move backup file', path, error)
return false
})
@ -86,10 +93,23 @@ class BackupManager {
}
const zip = new StreamZip.async({ file: tempPath })
const data = await zip.entryData('details')
var details = data.toString('utf8').split('\n')
let entries
try {
entries = await zip.entries()
} catch(error){
// Not a valid zip file
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
}
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
}
var backup = new Backup({ details, fullPath: tempPath })
const data = await zip.entryData('details')
const details = data.toString('utf8').split('\n')
const backup = new Backup({ details, fullPath: tempPath })
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
@ -98,7 +118,7 @@ class BackupManager {
backup.fileSize = await getFileSize(backup.fullPath)
var existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
const existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
if (existingBackupIndex >= 0) {
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
this.backups.splice(existingBackupIndex, 1, backup)
@ -122,14 +142,23 @@ class BackupManager {
}
}
async requestApplyBackup(backup) {
async requestApplyBackup(backup, res) {
const zip = new StreamZip.async({ file: backup.fullPath })
await zip.extract('config/', global.ConfigPath)
if (backup.backupMetadataCovers) {
await zip.extract('metadata-items/', this.ItemsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
const entries = await zip.entries()
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
Logger.error(`[BackupManager] Cannot apply old backup ${backup.fullPath}`)
return res.status(500).send('Invalid backup file. Does not include absdatabase.sqlite. This might be from an older Audiobookshelf server.')
}
await this.db.reinit()
await Database.disconnect()
await zip.extract('absdatabase.sqlite', global.ConfigPath)
await zip.extract('metadata-items/', this.ItemsMetadataPath)
await zip.extract('metadata-authors/', this.AuthorsMetadataPath)
await Database.reconnect()
SocketAuthority.emitter('backup_applied')
}
@ -157,8 +186,10 @@ class BackupManager {
const backup = new Backup({ details, fullPath: fullFilePath })
if (!backup.serverVersion) {
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.fullPath}"`)
if (!backup.serverVersion) { // Backups before v2
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.filename}"`)
} else if (!backup.key) { // Backups before sqlite migration
Logger.warn(`[BackupManager] Old unsupported backup was found "${backup.filename}" (pre sqlite migration)`)
}
backup.fileSize = await getFileSize(backup.fullPath)
@ -182,44 +213,52 @@ class BackupManager {
async runBackup() {
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
Logger.info(`[BackupManager] Running Backup`)
var newBackup = new Backup()
const newBackup = new Backup()
newBackup.setData(this.BackupPath)
const newBackData = {
backupMetadataCovers: this.serverSettings.backupMetadataCovers,
backupDirPath: this.BackupPath
await fs.ensureDir(this.AuthorsMetadataPath)
// Create backup sqlite file
const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => {
Logger.error(`[BackupManager] Failed to backup sqlite db`, error)
return false
})
if (!sqliteBackupPath) {
return false
}
newBackup.setData(newBackData)
var metadataAuthorsPath = this.AuthorsMetadataPath
if (!await fs.pathExists(metadataAuthorsPath)) metadataAuthorsPath = null
var zipResult = await this.zipBackup(metadataAuthorsPath, newBackup).then(() => true).catch((error) => {
// Zip sqlite file, /metadata/items, and /metadata/authors folders
const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`)
return false
})
if (zipResult) {
Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)
await filePerms.setDefault(newBackup.fullPath)
newBackup.fileSize = await getFileSize(newBackup.fullPath)
var existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
if (existingIndex >= 0) {
this.backups.splice(existingIndex, 1, newBackup)
} else {
this.backups.push(newBackup)
}
// Check remove oldest backup
if (this.backups.length > this.serverSettings.backupsToKeep) {
this.backups.sort((a, b) => a.createdAt - b.createdAt)
// Remove sqlite backup
await fs.remove(sqliteBackupPath)
var oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup)
}
return true
if (!zipResult) return false
Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)
newBackup.fileSize = await getFileSize(newBackup.fullPath)
const existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
if (existingIndex >= 0) {
this.backups.splice(existingIndex, 1, newBackup)
} else {
return false
this.backups.push(newBackup)
}
// Check remove oldest backup
if (this.backups.length > this.backupsToKeep) {
this.backups.sort((a, b) => a.createdAt - b.createdAt)
const oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup)
}
return true
}
async removeBackup(backup) {
@ -233,7 +272,35 @@ class BackupManager {
}
}
zipBackup(metadataAuthorsPath, backup) {
/**
* @see https://github.com/TryGhost/node-sqlite3/pull/1116
* @param {Backup} backup
* @promise
*/
backupSqliteDb(backup) {
const db = new sqlite3.Database(Database.dbPath)
const dbFilePath = Path.join(global.ConfigPath, `absdatabase.${backup.id}.sqlite`)
return new Promise(async (resolve, reject) => {
const backup = db.backup(dbFilePath)
backup.step(-1)
backup.finish()
// Max time ~2 mins
for (let i = 0; i < 240; i++) {
if (backup.completed) {
return resolve(dbFilePath)
} else if (backup.failed) {
return reject(backup.message || 'Unknown failure reason')
}
await new Promise((r) => setTimeout(r, 500))
}
Logger.error(`[BackupManager] Backup sqlite timed out`)
reject('Backup timed out')
})
}
zipBackup(sqliteBackupPath, backup) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(backup.fullPath)
@ -245,7 +312,7 @@ class BackupManager {
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info('[BackupManager]', archive.pointer() + ' total bytes')
resolve()
resolve(true)
})
// This event is fired when the data source is drained no matter what was the data source.
@ -281,7 +348,7 @@ class BackupManager {
reject(err)
})
archive.on('progress', ({ fs: fsobj }) => {
const maxBackupSizeInBytes = this.serverSettings.maxBackupSize * 1000 * 1000 * 1000
const maxBackupSizeInBytes = this.maxBackupSize * 1000 * 1000 * 1000
if (fsobj.processedBytes > maxBackupSizeInBytes) {
Logger.error(`[BackupManager] Archiver is too large - aborting to prevent endless loop, Bytes Processed: ${fsobj.processedBytes}`)
archive.abort()
@ -295,26 +362,9 @@ class BackupManager {
// pipe archive data to the file
archive.pipe(output)
archive.directory(Path.join(this.db.LibraryItemsPath, 'data'), 'config/libraryItems/data')
archive.directory(Path.join(this.db.UsersPath, 'data'), 'config/users/data')
archive.directory(Path.join(this.db.SessionsPath, 'data'), 'config/sessions/data')
archive.directory(Path.join(this.db.LibrariesPath, 'data'), 'config/libraries/data')
archive.directory(Path.join(this.db.SettingsPath, 'data'), 'config/settings/data')
archive.directory(Path.join(this.db.CollectionsPath, 'data'), 'config/collections/data')
archive.directory(Path.join(this.db.AuthorsPath, 'data'), 'config/authors/data')
archive.directory(Path.join(this.db.SeriesPath, 'data'), 'config/series/data')
archive.directory(Path.join(this.db.PlaylistsPath, 'data'), 'config/playlists/data')
archive.directory(Path.join(this.db.FeedsPath, 'data'), 'config/feeds/data')
if (this.serverSettings.backupMetadataCovers) {
Logger.debug(`[BackupManager] Backing up Metadata Items "${this.ItemsMetadataPath}"`)
archive.directory(this.ItemsMetadataPath, 'metadata-items')
if (metadataAuthorsPath) {
Logger.debug(`[BackupManager] Backing up Metadata Authors "${metadataAuthorsPath}"`)
archive.directory(metadataAuthorsPath, 'metadata-authors')
}
}
archive.file(sqliteBackupPath, { name: 'absdatabase.sqlite' })
archive.directory(this.ItemsMetadataPath, 'metadata-items')
archive.directory(this.AuthorsMetadataPath, 'metadata-authors')
archive.append(backup.detailsString, { name: 'details' })

View file

@ -53,7 +53,7 @@ class CacheManager {
if (await fs.pathExists(path)) {
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${path}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + path}).send()
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + path }).send()
}
const r = fs.createReadStream(path)
@ -79,7 +79,7 @@ class CacheManager {
if (global.XAccel) {
Logger.debug(`Use X-Accel to serve static file ${writtenFile}`)
return res.status(204).header({'X-Accel-Redirect': global.XAccel + writtenFile}).send()
return res.status(204).header({ 'X-Accel-Redirect': global.XAccel + writtenFile }).send()
}
var readStream = fs.createReadStream(writtenFile)
@ -116,6 +116,7 @@ class CacheManager {
}
async purgeAll() {
Logger.info(`[CacheManager] Purging all cache at "${this.CachePath}"`)
if (await fs.pathExists(this.CachePath)) {
await fs.remove(this.CachePath).catch((error) => {
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
@ -125,6 +126,7 @@ class CacheManager {
}
async purgeItems() {
Logger.info(`[CacheManager] Purging items cache at "${this.ItemCachePath}"`)
if (await fs.pathExists(this.ItemCachePath)) {
await fs.remove(this.ItemCachePath).catch((error) => {
Logger.error(`[CacheManager] Failed to remove items cache dir "${this.ItemCachePath}"`, error)

View file

@ -10,15 +10,14 @@ const { downloadFile, filePathToPOSIX } = require('../utils/fileUtils')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
class CoverManager {
constructor(db, cacheManager) {
this.db = db
constructor(cacheManager) {
this.cacheManager = cacheManager
this.ItemMetadataPath = Path.posix.join(global.MetadataPath, 'items')
}
getCoverDirectory(libraryItem) {
if (this.db.serverSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile && !libraryItem.isMusic) {
return libraryItem.path
} else {
return Path.posix.join(this.ItemMetadataPath, libraryItem.id)

View file

@ -1,9 +1,9 @@
const cron = require('../libs/nodeCron')
const Logger = require('../Logger')
const Database = require('../Database')
class CronManager {
constructor(db, scanner, podcastManager) {
this.db = db
constructor(scanner, podcastManager) {
this.scanner = scanner
this.podcastManager = podcastManager
@ -13,13 +13,21 @@ class CronManager {
this.podcastCronExpressionsExecuting = []
}
init() {
this.initLibraryScanCrons()
/**
* Initialize library scan crons & podcast download crons
* @param {oldLibrary[]} libraries
*/
init(libraries) {
this.initLibraryScanCrons(libraries)
this.initPodcastCrons()
}
initLibraryScanCrons() {
for (const library of this.db.libraries) {
/**
* Initialize library scan crons
* @param {oldLibrary[]} libraries
*/
initLibraryScanCrons(libraries) {
for (const library of libraries) {
if (library.settings.autoScanCronExpression) {
this.startCronForLibrary(library)
}
@ -64,7 +72,7 @@ class CronManager {
initPodcastCrons() {
const cronExpressionMap = {}
this.db.libraryItems.forEach((li) => {
Database.libraryItems.forEach((li) => {
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
if (!li.media.autoDownloadSchedule) {
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
@ -119,7 +127,7 @@ class CronManager {
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId)
const libraryItem = Database.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out

View file

@ -0,0 +1,72 @@
const nodemailer = require('nodemailer')
const Database = require('../Database')
const Logger = require("../Logger")
class EmailManager {
constructor() { }
getTransporter() {
return nodemailer.createTransport(Database.emailSettings.getTransportObject())
}
async sendTest(res) {
Logger.info(`[EmailManager] Sending test email`)
const transporter = this.getTransporter()
const success = await transporter.verify().catch((error) => {
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
return false
})
if (!success) {
return res.status(400).send('Failed to verify SMTP connection configuration')
}
transporter.sendMail({
from: Database.emailSettings.fromAddress,
to: Database.emailSettings.testAddress || Database.emailSettings.fromAddress,
subject: 'Test email from Audiobookshelf',
text: 'Success!'
}).then((result) => {
Logger.info(`[EmailManager] Test email sent successfully`, result)
res.sendStatus(200)
}).catch((error) => {
Logger.error(`[EmailManager] Failed to send test email`, error)
res.status(400).send(error.message || 'Failed to send test email')
})
}
async sendEBookToDevice(ebookFile, device, res) {
Logger.info(`[EmailManager] Sending ebook "${ebookFile.metadata.filename}" to device "${device.name}"/"${device.email}"`)
const transporter = this.getTransporter()
const success = await transporter.verify().catch((error) => {
Logger.error(`[EmailManager] Failed to verify SMTP connection config`, error)
return false
})
if (!success) {
return res.status(400).send('Failed to verify SMTP connection configuration')
}
transporter.sendMail({
from: Database.emailSettings.fromAddress,
to: device.email,
subject: "Here is your Ebook!",
html: '<div dir="auto"></div>',
attachments: [
{
filename: ebookFile.metadata.filename,
path: ebookFile.metadata.path,
}
]
}).then((result) => {
Logger.info(`[EmailManager] Ebook sent to device successfully`, result)
res.sendStatus(200)
}).catch((error) => {
Logger.error(`[EmailManager] Failed to send ebook to device`, error)
res.status(400).send(error.message || 'Failed to send ebook to device')
})
}
}
module.exports = EmailManager

View file

@ -9,9 +9,7 @@ const Logger = require('../Logger')
const TAG = '[LogManager]'
class LogManager {
constructor(db) {
this.db = db
constructor() {
this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
@ -20,12 +18,8 @@ class LogManager {
this.dailyLogFiles = []
}
get serverSettings() {
return this.db.serverSettings || {}
}
get loggerDailyLogsToKeep() {
return this.serverSettings.loggerDailyLogsToKeep || 7
return global.ServerSettings.loggerDailyLogsToKeep || 7
}
async ensureLogDirs() {

View file

@ -1,12 +1,11 @@
const axios = require('axios')
const Logger = require("../Logger")
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { notificationData } = require('../utils/notifications')
class NotificationManager {
constructor(db) {
this.db = db
constructor() {
this.sendingNotification = false
this.notificationQueue = []
}
@ -15,15 +14,15 @@ class NotificationManager {
return notificationData
}
onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!this.db.notificationSettings.isUseable) return
async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
const library = await Database.models.library.getOldById(libraryItem.libraryId)
const eventData = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
libraryName: library ? library.name : 'Unknown',
libraryName: library?.name || 'Unknown',
mediaTags: (libraryItem.media.tags || []).join(', '),
podcastTitle: libraryItem.media.metadata.title,
podcastAuthor: libraryItem.media.metadata.author || '',
@ -42,19 +41,19 @@ class NotificationManager {
}
async triggerNotification(eventName, eventData, intentionallyFail = false) {
if (!this.db.notificationSettings.isUseable) return
if (!Database.notificationSettings.isUseable) return
// Will queue the notification if sendingNotification and queue is not full
if (!this.checkTriggerNotification(eventName, eventData)) return
const notifications = this.db.notificationSettings.getActiveNotificationsForEvent(eventName)
const notifications = Database.notificationSettings.getActiveNotificationsForEvent(eventName)
for (const notification of notifications) {
Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`)
const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)
notification.updateNotificationFired(success)
if (!success) { // Failed notification
if (notification.numConsecutiveFailedAttempts >= this.db.notificationSettings.maxFailedAttempts) {
if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) {
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)
notification.enabled = false
} else {
@ -63,8 +62,8 @@ class NotificationManager {
}
}
await this.db.updateEntity('settings', this.db.notificationSettings)
SocketAuthority.emitter('notifications_updated', this.db.notificationSettings.toJSON())
await Database.updateSetting(Database.notificationSettings)
SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON())
this.notificationFinished()
}
@ -72,7 +71,7 @@ class NotificationManager {
// Return TRUE if notification should be triggered now
checkTriggerNotification(eventName, eventData) {
if (this.sendingNotification) {
if (this.notificationQueue.length >= this.db.notificationSettings.maxNotificationQueue) {
if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) {
Logger.warn(`[NotificationManager] Notification queue is full - ignoring event ${eventName}`)
} else {
Logger.debug(`[NotificationManager] Queueing notification ${eventName} (Queue size: ${this.notificationQueue.length})`)
@ -92,7 +91,7 @@ class NotificationManager {
const nextNotificationEvent = this.notificationQueue.shift()
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
}
}, this.db.notificationSettings.notificationDelay)
}, Database.notificationSettings.notificationDelay)
}
sendTestNotification(notification) {
@ -107,7 +106,7 @@ class NotificationManager {
sendNotification(notification, eventData) {
const payload = notification.getApprisePayload(eventData)
return axios.post(this.db.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => {
return axios.post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => {
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
return true
}).catch((error) => {

View file

@ -1,7 +1,9 @@
const uuidv4 = require("uuid").v4
const Path = require('path')
const serverVersion = require('../../package.json').version
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const date = require('../libs/dateAndTime')
const fs = require('../libs/fsExtra')
@ -15,10 +17,10 @@ const DeviceInfo = require('../objects/DeviceInfo')
const Stream = require('../objects/Stream')
class PlaybackSessionManager {
constructor(db) {
this.db = db
constructor() {
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
this.oldPlaybackSessionMap = {} // TODO: Remove after updated mobile versions
this.sessions = []
}
@ -33,19 +35,32 @@ class PlaybackSessionManager {
return session?.stream || null
}
getDeviceInfo(req) {
async getDeviceInfo(req) {
const ua = uaParserJs(req.headers['user-agent'])
const ip = requestIp.getClientIp(req)
const clientDeviceInfo = req.body?.deviceInfo || null
const deviceInfo = new DeviceInfo()
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion)
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user.id)
if (clientDeviceInfo?.deviceId) {
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
if (existingDevice) {
if (existingDevice.update(deviceInfo)) {
await Database.updateDevice(existingDevice)
}
return existingDevice
}
}
await Database.createDevice(deviceInfo)
return deviceInfo
}
async startSessionRequest(req, res, episodeId) {
const deviceInfo = this.getDeviceInfo(req)
const deviceInfo = await this.getDeviceInfo(req)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { user, libraryItem, body: options } = req
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
@ -61,13 +76,14 @@ class PlaybackSessionManager {
}
async syncLocalSessionsRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const sessions = req.body.sessions || []
const syncResults = []
for (const sessionJson of sessions) {
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
const result = await this.syncLocalSession(user, sessionJson)
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
syncResults.push(result)
}
@ -76,8 +92,8 @@ class PlaybackSessionManager {
})
}
async syncLocalSession(user, sessionJson) {
const libraryItem = this.db.getLibraryItem(sessionJson.libraryItemId)
async syncLocalSession(user, sessionJson, deviceInfo) {
const libraryItem = Database.getLibraryItem(sessionJson.libraryItemId)
const episode = (sessionJson.episodeId && libraryItem && libraryItem.isPodcast) ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
@ -88,12 +104,42 @@ class PlaybackSessionManager {
}
}
let session = await this.db.getPlaybackSession(sessionJson.id)
sessionJson.userId = user.id
sessionJson.serverVersion = serverVersion
// TODO: Temp update local playback session id to uuidv4 & library item/book/episode ids
if (sessionJson.id?.startsWith('play_local_')) {
if (!this.oldPlaybackSessionMap[sessionJson.id]) {
const newSessionId = uuidv4()
this.oldPlaybackSessionMap[sessionJson.id] = newSessionId
sessionJson.id = newSessionId
} else {
sessionJson.id = this.oldPlaybackSessionMap[sessionJson.id]
}
}
if (sessionJson.libraryItemId !== libraryItem.id) {
Logger.info(`[PlaybackSessionManager] Mapped old libraryItemId "${sessionJson.libraryItemId}" to ${libraryItem.id}`)
sessionJson.libraryItemId = libraryItem.id
sessionJson.bookId = episode ? null : libraryItem.media.id
}
if (!sessionJson.bookId && !episode) {
sessionJson.bookId = libraryItem.media.id
}
if (episode && sessionJson.episodeId !== episode.id) {
Logger.info(`[PlaybackSessionManager] Mapped old episodeId "${sessionJson.episodeId}" to ${episode.id}`)
sessionJson.episodeId = episode.id
}
if (sessionJson.libraryId !== libraryItem.libraryId) {
sessionJson.libraryId = libraryItem.libraryId
}
let session = await Database.getPlaybackSession(sessionJson.id)
if (!session) {
// New session from local
session = new PlaybackSession(sessionJson)
session.deviceInfo = deviceInfo
Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`)
await this.db.insertEntity('session', session)
await Database.createPlaybackSession(session)
} else {
session.currentTime = sessionJson.currentTime
session.timeListening = sessionJson.timeListening
@ -102,7 +148,7 @@ class PlaybackSessionManager {
session.dayOfWeek = date.format(new Date(), 'dddd')
Logger.debug(`[PlaybackSessionManager] Updated session for "${session.displayTitle}" (${session.id})`)
await this.db.updateEntity('session', session)
await Database.updatePlaybackSession(session)
}
const result = {
@ -126,10 +172,12 @@ class PlaybackSessionManager {
// Update user and emit socket event
if (result.progressSynced) {
await this.db.updateEntity('user', user)
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
if (itemProgress) await Database.upsertMediaProgress(itemProgress)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON()
})
}
@ -137,8 +185,11 @@ class PlaybackSessionManager {
return result
}
async syncLocalSessionRequest(user, sessionJson, res) {
const result = await this.syncLocalSession(user, sessionJson)
async syncLocalSessionRequest(req, res) {
const deviceInfo = await this.getDeviceInfo(req)
const user = req.user
const sessionJson = req.body
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
if (result.error) {
res.status(500).send(result.error)
} else {
@ -153,7 +204,7 @@ class PlaybackSessionManager {
async startSession(user, deviceInfo, libraryItem, episodeId, options) {
// Close any sessions already open for user and device
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.deviceId)
const userSessions = this.sessions.filter(playbackSession => playbackSession.userId === user.id && playbackSession.deviceId === deviceInfo.id)
for (const session of userSessions) {
Logger.info(`[PlaybackSessionManager] startSession: Closing open session "${session.displayTitle}" for user "${user.username}" (Device: ${session.deviceDescription})`)
await this.closeSession(user, session, null)
@ -207,17 +258,14 @@ class PlaybackSessionManager {
newPlaybackSession.audioTracks = audioTracks
}
// Will save on the first sync
user.currentSessionId = newPlaybackSession.id
this.sessions.push(newPlaybackSession)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
return newPlaybackSession
}
async syncSession(user, session, syncData) {
const libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
const libraryItem = Database.libraryItems.find(li => li.id === session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
@ -234,11 +282,12 @@ class PlaybackSessionManager {
}
const wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
if (wasUpdated) {
await this.db.updateEntity('user', user)
const itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
if (itemProgress) await Database.upsertMediaProgress(itemProgress)
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
sessionId: session.id,
deviceDescription: session.deviceDescription,
data: itemProgress.toJSON()
})
}
@ -255,7 +304,7 @@ class PlaybackSessionManager {
await this.saveSession(session)
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
SocketAuthority.adminEmitter('user_stream_update', user.toJSONForPublic(this.sessions, Database.libraryItems))
SocketAuthority.clientEmitter(session.userId, 'user_session_closed', session.id)
return this.removeSession(session.id)
}
@ -264,10 +313,10 @@ class PlaybackSessionManager {
if (!session.timeListening) return // Do not save a session with no listening time
if (session.lastSave) {
return this.db.updateEntity('session', session)
return Database.updatePlaybackSession(session)
} else {
session.lastSave = Date.now()
return this.db.insertEntity('session', session)
return Database.createPlaybackSession(session)
}
}
@ -301,16 +350,5 @@ class PlaybackSessionManager {
Logger.error(`[PlaybackSessionManager] cleanOrphanStreams failed`, error)
}
}
// Android app v0.9.54 and below had a bug where listening time was sending unix timestamp
// See https://github.com/advplyr/audiobookshelf/issues/868
// Remove playback sessions with listening time too high
async removeInvalidSessions() {
const selectFunc = (session) => isNaN(session.timeListening) || Number(session.timeListening) > 3600000000
const numSessionsRemoved = await this.db.removeEntities('session', selectFunc, true)
if (numSessionsRemoved) {
Logger.info(`[PlaybackSessionManager] Removed ${numSessionsRemoved} invalid playback sessions`)
}
}
}
module.exports = PlaybackSessionManager

View file

@ -1,5 +1,6 @@
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
@ -8,6 +9,7 @@ const { removeFile, downloadFile } = require('../utils/fileUtils')
const filePerms = require('../utils/filePerms')
const { levenshteinDistance } = require('../utils/index')
const opmlParser = require('../utils/parsers/parseOPML')
const opmlGenerator = require('../utils/generators/opmlGenerator')
const prober = require('../utils/prober')
const ffmpegHelpers = require('../utils/ffmpegHelpers')
@ -18,8 +20,7 @@ const AudioFile = require('../objects/files/AudioFile')
const Task = require("../objects/Task")
class PodcastManager {
constructor(db, watcher, notificationManager, taskManager) {
this.db = db
constructor(watcher, notificationManager, taskManager) {
this.watcher = watcher
this.notificationManager = notificationManager
this.taskManager = taskManager
@ -31,10 +32,6 @@ class PodcastManager {
this.MaxFailedEpisodeChecks = 24
}
get serverSettings() {
return this.db.serverSettings || {}
}
getEpisodeDownloadsInQueue(libraryItemId) {
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
}
@ -53,11 +50,12 @@ class PodcastManager {
}
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
let index = libraryItem.media.episodes.length + 1
let index = Math.max(...libraryItem.media.episodes.filter(ep => ep.index == null || isNaN(ep.index)).map(ep => Number(ep.index))) + 1
for (const ep of episodesToDownload) {
const newPe = new PodcastEpisode()
newPe.setData(ep, index++)
newPe.libraryItemId = libraryItem.id
newPe.podcastId = libraryItem.media.id
const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl)
@ -78,12 +76,19 @@ class PodcastManager {
libraryId: podcastEpisodeDownload.libraryId,
libraryItemId: podcastEpisodeDownload.libraryItemId,
}
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, taskData)
task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData)
this.taskManager.addTask(task)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload
// If this file already exists then append the episode id to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendEpisodeId = true
}
// Ignores all added files to this dir
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
@ -140,14 +145,12 @@ class PodcastManager {
async scanAddPodcastEpisodeAudioFile() {
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
// TODO: Set meta tags on new audio file
const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) {
return false
}
const libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
const libraryItem = Database.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false
@ -176,8 +179,11 @@ class PodcastManager {
}
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded()
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes
this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode)
@ -226,6 +232,7 @@ class PodcastManager {
}
const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
newAudioFile.index = 1
return newAudioFile
}
@ -265,7 +272,7 @@ class PodcastManager {
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return libraryItem.media.autoDownloadEpisodes
}
@ -304,7 +311,7 @@ class PodcastManager {
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
return newEpisodes
@ -365,6 +372,10 @@ class PodcastManager {
}
}
generateOPMLFileText(libraryItems) {
return opmlGenerator.generate(libraryItems)
}
getDownloadQueueDetails(libraryId = null) {
let _currentDownload = this.currentDownload
if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null

View file

@ -2,35 +2,29 @@ const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
class RssFeedManager {
constructor(db) {
this.db = db
constructor() { }
this.feeds = {}
}
get feedsArray() {
return Object.values(this.feeds)
}
validateFeedEntity(feedObj) {
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
if (!this.db.collections.some(li => li.id === feedObj.entityId)) {
const collection = await Database.models.collection.getById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
if (!this.db.libraryItems.some(li => li.id === feedObj.entityId)) {
if (!Database.libraryItems.some(li => li.id === feedObj.entityId)) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = this.db.series.find(s => s.id === feedObj.entityId)
const hasSeriesBook = this.db.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
const series = Database.series.find(s => s.id === feedObj.entityId)
const hasSeriesBook = series ? Database.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) : false
if (!hasSeriesBook) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`)
return false
@ -42,47 +36,57 @@ class RssFeedManager {
return true
}
/**
* Validate all feeds and remove invalid
*/
async init() {
const feedObjects = await this.db.getAllEntities('feed')
if (!feedObjects || !feedObjects.length) return
for (const feedObj of feedObjects) {
// Migration: In v2.2.12 entityType "item" was updated to "libraryItem"
if (feedObj.entityType === 'item') {
feedObj.entityType = 'libraryItem'
await this.db.updateEntity('feed', feedObj)
}
const feeds = await Database.models.feed.getOldFeeds()
for (const feed of feeds) {
// Remove invalid feeds
if (!this.validateFeedEntity(feedObj)) {
await this.db.removeEntity('feed', feedObj.id)
if (!await this.validateFeedEntity(feed)) {
await Database.removeFeed(feed.id)
}
const feed = new Feed(feedObj)
this.feeds[feed.id] = feed
Logger.info(`[RssFeedManager] Opened rss feed ${feed.feedUrl}`)
}
}
/**
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedForEntityId(entityId) {
return Object.values(this.feeds).find(feed => feed.entityId === entityId)
return Database.models.feed.findOneOld({ entityId })
}
findFeed(feedId) {
return this.feeds[feedId] || null
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeedBySlug(slug) {
return Database.models.feed.findOneOld({ slug })
}
/**
* Find open feed for a slug
* @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed
*/
findFeed(id) {
return Database.models.feed.findByPkOld(id)
}
async getFeed(req, res) {
const feed = this.feeds[req.params.id]
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
return
}
// Check if feed needs to be updated
if (feed.entityType === 'libraryItem') {
const libraryItem = this.db.getLibraryItem(feed.entityId)
const libraryItem = Database.getLibraryItem(feed.entityId)
let mostRecentlyUpdatedAt = libraryItem.updatedAt
if (libraryItem.isPodcast) {
@ -93,13 +97,14 @@ class RssFeedManager {
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await this.db.updateEntity('feed', feed)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = this.db.collections.find(c => c.id === feed.entityId)
const collection = await Database.models.collection.getById(feed.entityId)
if (collection) {
const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems)
const collectionExpanded = collection.toJSONExpanded(Database.libraryItems)
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
@ -113,15 +118,15 @@ class RssFeedManager {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await this.db.updateEntity('feed', feed)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = this.db.series.find(s => s.id === feed.entityId)
const series = Database.series.find(s => s.id === feed.entityId)
if (series) {
const seriesJson = series.toJSON()
// Get books in series that have audio tracks
seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
seriesJson.books = Database.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length)
// Find most recently updated item in series
let mostRecentlyUpdatedAt = seriesJson.updatedAt
@ -140,7 +145,7 @@ class RssFeedManager {
Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`)
feed.updateFromSeries(seriesJson)
await this.db.updateEntity('feed', feed)
await Database.updateFeed(feed)
}
}
}
@ -150,10 +155,10 @@ class RssFeedManager {
res.send(xml)
}
getFeedItem(req, res) {
const feed = this.feeds[req.params.id]
async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
return
}
@ -166,10 +171,10 @@ class RssFeedManager {
res.sendFile(episodePath)
}
getFeedCover(req, res) {
const feed = this.feeds[req.params.id]
async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug)
if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.id}`)
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404)
return
}
@ -194,10 +199,9 @@ class RssFeedManager {
const feed = new Feed()
feed.setFromItem(user.id, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await this.db.insertEntity('feed', feed)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
@ -211,10 +215,9 @@ class RssFeedManager {
const feed = new Feed()
feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await this.db.insertEntity('feed', feed)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
@ -228,29 +231,32 @@ class RssFeedManager {
const feed = new Feed()
feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail)
this.feeds[feed.id] = feed
Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await this.db.insertEntity('feed', feed)
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
await Database.createFeed(feed)
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
return feed
}
async handleCloseFeed(feed) {
if (!feed) return
await this.db.removeEntity('feed', feed.id)
await Database.removeFeed(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
delete this.feeds[feed.id]
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
}
closeRssFeed(id) {
if (!this.feeds[id]) return
return this.handleCloseFeed(this.feeds[id])
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
}
await this.handleCloseFeed(feed)
res.sendStatus(200)
}
closeFeedForEntityId(entityId) {
const feed = this.findFeedForEntityId(entityId)
async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId)
if (!feed) return
return this.handleCloseFeed(feed)
}

88
server/models/Author.js Normal file
View file

@ -0,0 +1,88 @@
const { DataTypes, Model } = require('sequelize')
const oldAuthor = require('../objects/entities/Author')
module.exports = (sequelize) => {
class Author extends Model {
static async getOldAuthors() {
const authors = await this.findAll()
return authors.map(au => au.getOldAuthor())
}
getOldAuthor() {
return new oldAuthor({
id: this.id,
asin: this.asin,
name: this.name,
description: this.description,
imagePath: this.imagePath,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static updateFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.update(author, {
where: {
id: author.id
}
})
}
static createFromOld(oldAuthor) {
const author = this.getFromOld(oldAuthor)
return this.create(author)
}
static createBulkFromOld(oldAuthors) {
const authors = oldAuthors.map(this.getFromOld)
return this.bulkCreate(authors)
}
static getFromOld(oldAuthor) {
return {
id: oldAuthor.id,
name: oldAuthor.name,
lastFirst: oldAuthor.lastFirst,
asin: oldAuthor.asin,
description: oldAuthor.description,
imagePath: oldAuthor.imagePath,
libraryId: oldAuthor.libraryId
}
}
static removeById(authorId) {
return this.destroy({
where: {
id: authorId
}
})
}
}
Author.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
lastFirst: DataTypes.STRING,
asin: DataTypes.STRING,
description: DataTypes.TEXT,
imagePath: DataTypes.STRING
}, {
sequelize,
modelName: 'author'
})
const { library } = sequelize.models
library.hasMany(Author, {
onDelete: 'CASCADE'
})
Author.belongsTo(library)
return Author
}

178
server/models/Book.js Normal file
View file

@ -0,0 +1,178 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
module.exports = (sequelize) => {
class Book extends Model {
static getOldBook(libraryItemExpanded) {
const bookExpanded = libraryItemExpanded.media
let authors = []
if (bookExpanded.authors?.length) {
authors = bookExpanded.authors.map(au => {
return {
id: au.id,
name: au.name
}
})
} else if (bookExpanded.bookAuthors?.length) {
authors = bookExpanded.bookAuthors.map(ba => {
if (ba.author) {
return {
id: ba.author.id,
name: ba.author.name
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
return null
}
}).filter(a => a)
}
let series = []
if (bookExpanded.series?.length) {
series = bookExpanded.series.map(se => {
return {
id: se.id,
name: se.name,
sequence: se.bookSeries.sequence
}
})
} else if (bookExpanded.bookSeries?.length) {
series = bookExpanded.bookSeries.map(bs => {
if (bs.series) {
return {
id: bs.series.id,
name: bs.series.name,
sequence: bs.sequence
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
return null
}
}).filter(s => s)
}
return {
id: bookExpanded.id,
libraryItemId: libraryItemExpanded.id,
coverPath: bookExpanded.coverPath,
tags: bookExpanded.tags,
audioFiles: bookExpanded.audioFiles,
chapters: bookExpanded.chapters,
ebookFile: bookExpanded.ebookFile,
metadata: {
title: bookExpanded.title,
subtitle: bookExpanded.subtitle,
authors: authors,
narrators: bookExpanded.narrators,
series: series,
genres: bookExpanded.genres,
publishedYear: bookExpanded.publishedYear,
publishedDate: bookExpanded.publishedDate,
publisher: bookExpanded.publisher,
description: bookExpanded.description,
isbn: bookExpanded.isbn,
asin: bookExpanded.asin,
language: bookExpanded.language,
explicit: bookExpanded.explicit,
abridged: bookExpanded.abridged
}
}
}
/**
* @param {object} oldBook
* @returns {boolean} true if updated
*/
static saveFromOld(oldBook) {
const book = this.getFromOld(oldBook)
return this.update(book, {
where: {
id: book.id
}
}).then(result => result[0] > 0).catch((error) => {
Logger.error(`[Book] Failed to save book ${book.id}`, error)
return false
})
}
static getFromOld(oldBook) {
return {
id: oldBook.id,
title: oldBook.metadata.title,
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
subtitle: oldBook.metadata.subtitle,
publishedYear: oldBook.metadata.publishedYear,
publishedDate: oldBook.metadata.publishedDate,
publisher: oldBook.metadata.publisher,
description: oldBook.metadata.description,
isbn: oldBook.metadata.isbn,
asin: oldBook.metadata.asin,
language: oldBook.metadata.language,
explicit: !!oldBook.metadata.explicit,
abridged: !!oldBook.metadata.abridged,
narrators: oldBook.metadata.narrators,
ebookFile: oldBook.ebookFile?.toJSON() || null,
coverPath: oldBook.coverPath,
duration: oldBook.duration,
audioFiles: oldBook.audioFiles?.map(af => af.toJSON()) || [],
chapters: oldBook.chapters,
tags: oldBook.tags,
genres: oldBook.metadata.genres
}
}
}
Book.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING,
subtitle: DataTypes.STRING,
publishedYear: DataTypes.STRING,
publishedDate: DataTypes.STRING,
publisher: DataTypes.STRING,
description: DataTypes.TEXT,
isbn: DataTypes.STRING,
asin: DataTypes.STRING,
language: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
abridged: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING,
duration: DataTypes.FLOAT,
narrators: DataTypes.JSON,
audioFiles: DataTypes.JSON,
ebookFile: DataTypes.JSON,
chapters: DataTypes.JSON,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'book',
indexes: [
{
fields: [{
name: 'title',
collate: 'NOCASE'
}]
},
{
fields: [{
name: 'titleIgnorePrefix',
collate: 'NOCASE'
}]
},
{
fields: ['publishedYear']
},
{
fields: ['duration']
}
]
})
return Book
}

View file

@ -0,0 +1,41 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class BookAuthor extends Model {
static removeByIds(authorId = null, bookId = null) {
const where = {}
if (authorId) where.authorId = authorId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
}
BookAuthor.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
}
}, {
sequelize,
modelName: 'bookAuthor',
timestamps: true,
updatedAt: false
})
// Super Many-to-Many
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
const { book, author } = sequelize.models
book.belongsToMany(author, { through: BookAuthor })
author.belongsToMany(book, { through: BookAuthor })
book.hasMany(BookAuthor)
BookAuthor.belongsTo(book)
author.hasMany(BookAuthor)
BookAuthor.belongsTo(author)
return BookAuthor
}

View file

@ -0,0 +1,42 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class BookSeries extends Model {
static removeByIds(seriesId = null, bookId = null) {
const where = {}
if (seriesId) where.seriesId = seriesId
if (bookId) where.bookId = bookId
return this.destroy({
where
})
}
}
BookSeries.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
sequence: DataTypes.STRING
}, {
sequelize,
modelName: 'bookSeries',
timestamps: true,
updatedAt: false
})
// Super Many-to-Many
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
const { book, series } = sequelize.models
book.belongsToMany(series, { through: BookSeries })
series.belongsToMany(book, { through: BookSeries })
book.hasMany(BookSeries)
BookSeries.belongsTo(book)
series.hasMany(BookSeries)
BookSeries.belongsTo(series)
return BookSeries
}

284
server/models/Collection.js Normal file
View file

@ -0,0 +1,284 @@
const { DataTypes, Model } = require('sequelize')
const oldCollection = require('../objects/Collection')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Collection extends Model {
/**
* Get all old collections
* @returns {Promise<oldCollection[]>}
*/
static async getOldCollections() {
const collections = await this.findAll({
include: {
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
}
/**
* Get all old collections toJSONExpanded, items filtered for user permissions
* @param {[oldUser]} user
* @param {[string]} libraryId
* @param {[string[]]} include
* @returns {Promise<object[]>} oldCollection.toJSONExpanded
*/
static async getOldCollectionsJsonExpanded(user, libraryId, include) {
let collectionWhere = null
if (libraryId) {
collectionWhere = {
libraryId
}
}
// Optionally include rssfeed for collection
const collectionIncludes = []
if (include.includes('rssfeed')) {
collectionIncludes.push({
model: sequelize.models.feed
})
}
const collections = await this.findAll({
where: collectionWhere,
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.libraryItem
},
{
model: sequelize.models.author,
through: {
attributes: []
}
},
{
model: sequelize.models.series,
through: {
attributes: ['sequence']
}
},
]
},
...collectionIncludes
],
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
// TODO: Handle user permission restrictions on initial query
return collections.map(c => {
const oldCollection = this.getOldCollection(c)
// Filter books using user permissions
const books = c.books?.filter(b => {
if (user) {
if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) {
return false
}
if (b.explicit === true && !user.canAccessExplicitContent) {
return false
}
}
return true
}) || []
// Map to library items
const libraryItems = books.map(b => {
const libraryItem = b.libraryItem
delete b.libraryItem
libraryItem.media = b
return sequelize.models.libraryItem.getOldLibraryItem(libraryItem)
})
// Users with restricted permissions will not see this collection
if (!books.length && oldCollection.books.length) {
return null
}
const collectionExpanded = oldCollection.toJSONExpanded(libraryItems)
// Map feed if found
if (c.feeds?.length) {
collectionExpanded.rssFeed = sequelize.models.feed.getOldFeed(c.feeds[0])
}
return collectionExpanded
}).filter(c => c)
}
/**
* Get old collection from Collection
* @param {Collection} collectionExpanded
* @returns {oldCollection}
*/
static getOldCollection(collectionExpanded) {
const libraryItemIds = collectionExpanded.books?.map(b => b.libraryItem?.id || null).filter(lid => lid) || []
return new oldCollection({
id: collectionExpanded.id,
libraryId: collectionExpanded.libraryId,
name: collectionExpanded.name,
description: collectionExpanded.description,
books: libraryItemIds,
lastUpdate: collectionExpanded.updatedAt.valueOf(),
createdAt: collectionExpanded.createdAt.valueOf()
})
}
static createFromOld(oldCollection) {
const collection = this.getFromOld(oldCollection)
return this.create(collection)
}
static async fullUpdateFromOld(oldCollection, collectionBooks) {
const existingCollection = await this.findByPk(oldCollection.id, {
include: sequelize.models.collectionBook
})
if (!existingCollection) return false
let hasUpdates = false
const collection = this.getFromOld(oldCollection)
for (const cb of collectionBooks) {
const existingCb = existingCollection.collectionBooks.find(i => i.bookId === cb.bookId)
if (!existingCb) {
await sequelize.models.collectionBook.create(cb)
hasUpdates = true
} else if (existingCb.order != cb.order) {
await existingCb.update({ order: cb.order })
hasUpdates = true
}
}
for (const cb of existingCollection.collectionBooks) {
// collectionBook was removed
if (!collectionBooks.some(i => i.bookId === cb.bookId)) {
await cb.destroy()
hasUpdates = true
}
}
let hasCollectionUpdates = false
for (const key in collection) {
let existingValue = existingCollection[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(collection[key], existingValue)) {
hasCollectionUpdates = true
}
}
if (hasCollectionUpdates) {
existingCollection.update(collection)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldCollection) {
return {
id: oldCollection.id,
name: oldCollection.name,
description: oldCollection.description,
libraryId: oldCollection.libraryId
}
}
static removeById(collectionId) {
return this.destroy({
where: {
id: collectionId
}
})
}
/**
* Get collection by id
* @param {string} collectionId
* @returns {Promise<oldCollection|null>} returns null if not found
*/
static async getById(collectionId) {
if (!collectionId) return null
const collection = await this.findByPk(collectionId, {
include: {
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
if (!collection) return null
return this.getOldCollection(collection)
}
/**
* Remove all collections belonging to library
* @param {string} libraryId
* @returns {Promise<number>} number of collections destroyed
*/
static async removeAllForLibrary(libraryId) {
if (!libraryId) return 0
return this.destroy({
where: {
libraryId
}
})
}
/**
* Get all collections for a library
* @param {string} libraryId
* @returns {Promise<oldCollection[]>}
*/
static async getAllForLibrary(libraryId) {
if (!libraryId) return []
const collections = await this.findAll({
where: {
libraryId
},
include: {
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
}
static async getAllForBook(bookId) {
const collections = await this.findAll({
include: {
model: sequelize.models.book,
where: {
id: bookId
},
required: true,
include: sequelize.models.libraryItem
},
order: [[sequelize.models.book, sequelize.models.collectionBook, 'order', 'ASC']]
})
return collections.map(c => this.getOldCollection(c))
}
}
Collection.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'collection'
})
const { library } = sequelize.models
library.hasMany(Collection)
Collection.belongsTo(library)
return Collection
}

View file

@ -0,0 +1,46 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class CollectionBook extends Model {
static removeByIds(collectionId, bookId) {
return this.destroy({
where: {
bookId,
collectionId
}
})
}
}
CollectionBook.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'collectionBook'
})
// Super Many-to-Many
// ref: https://sequelize.org/docs/v6/advanced-association-concepts/advanced-many-to-many/#the-best-of-both-worlds-the-super-many-to-many-relationship
const { book, collection } = sequelize.models
book.belongsToMany(collection, { through: CollectionBook })
collection.belongsToMany(book, { through: CollectionBook })
book.hasMany(CollectionBook, {
onDelete: 'CASCADE'
})
CollectionBook.belongsTo(book)
collection.hasMany(CollectionBook, {
onDelete: 'CASCADE'
})
CollectionBook.belongsTo(collection)
return CollectionBook
}

116
server/models/Device.js Normal file
View file

@ -0,0 +1,116 @@
const { DataTypes, Model } = require('sequelize')
const oldDevice = require('../objects/DeviceInfo')
module.exports = (sequelize) => {
class Device extends Model {
getOldDevice() {
let browserVersion = null
let sdkVersion = null
if (this.clientName === 'Abs Android') {
sdkVersion = this.deviceVersion || null
} else {
browserVersion = this.deviceVersion || null
}
return new oldDevice({
id: this.id,
deviceId: this.deviceId,
userId: this.userId,
ipAddress: this.ipAddress,
browserName: this.extraData.browserName || null,
browserVersion,
osName: this.extraData.osName || null,
osVersion: this.extraData.osVersion || null,
clientVersion: this.clientVersion || null,
manufacturer: this.extraData.manufacturer || null,
model: this.extraData.model || null,
sdkVersion,
deviceName: this.deviceName,
clientName: this.clientName
})
}
static async getOldDeviceByDeviceId(deviceId) {
const device = await this.findOne({
where: {
deviceId
}
})
if (!device) return null
return device.getOldDevice()
}
static createFromOld(oldDevice) {
const device = this.getFromOld(oldDevice)
return this.create(device)
}
static updateFromOld(oldDevice) {
const device = this.getFromOld(oldDevice)
return this.update(device, {
where: {
id: device.id
}
})
}
static getFromOld(oldDeviceInfo) {
let extraData = {}
if (oldDeviceInfo.manufacturer) {
extraData.manufacturer = oldDeviceInfo.manufacturer
}
if (oldDeviceInfo.model) {
extraData.model = oldDeviceInfo.model
}
if (oldDeviceInfo.osName) {
extraData.osName = oldDeviceInfo.osName
}
if (oldDeviceInfo.osVersion) {
extraData.osVersion = oldDeviceInfo.osVersion
}
if (oldDeviceInfo.browserName) {
extraData.browserName = oldDeviceInfo.browserName
}
return {
id: oldDeviceInfo.id,
deviceId: oldDeviceInfo.deviceId,
clientName: oldDeviceInfo.clientName || null,
clientVersion: oldDeviceInfo.clientVersion || null,
ipAddress: oldDeviceInfo.ipAddress,
deviceName: oldDeviceInfo.deviceName || null,
deviceVersion: oldDeviceInfo.sdkVersion || oldDeviceInfo.browserVersion || null,
userId: oldDeviceInfo.userId,
extraData
}
}
}
Device.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
deviceId: DataTypes.STRING,
clientName: DataTypes.STRING, // e.g. Abs Web, Abs Android
clientVersion: DataTypes.STRING, // e.g. Server version or mobile version
ipAddress: DataTypes.STRING,
deviceName: DataTypes.STRING, // e.g. Windows 10 Chrome, Google Pixel 6, Apple iPhone 10,3
deviceVersion: DataTypes.STRING, // e.g. Browser version or Android SDK
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'device'
})
const { user } = sequelize.models
user.hasMany(Device, {
onDelete: 'CASCADE'
})
Device.belongsTo(user)
return Device
}

307
server/models/Feed.js Normal file
View file

@ -0,0 +1,307 @@
const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed')
const areEquivalent = require('../utils/areEquivalent')
/*
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
* Feeds can be created from LibraryItem, Collection, Playlist or Series
*/
module.exports = (sequelize) => {
class Feed extends Model {
static async getOldFeeds() {
const feeds = await this.findAll({
include: {
model: sequelize.models.feedEpisode
}
})
return feeds.map(f => this.getOldFeed(f))
}
/**
* Get old feed from Feed and optionally Feed with FeedEpisodes
* @param {Feed} feedExpanded
* @returns {oldFeed}
*/
static getOldFeed(feedExpanded) {
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return new oldFeed({
id: feedExpanded.id,
slug: feedExpanded.slug,
userId: feedExpanded.userId,
entityType: feedExpanded.entityType,
entityId: feedExpanded.entityId,
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
coverPath: feedExpanded.coverPath || null,
meta: {
title: feedExpanded.title,
description: feedExpanded.description,
author: feedExpanded.author,
imageUrl: feedExpanded.imageURL,
feedUrl: feedExpanded.feedURL,
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
type: feedExpanded.podcastType,
language: feedExpanded.language,
preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) {
return this.destroy({
where: {
id: feedId
}
})
}
/**
* Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<Array<String>>} array of library item ids
*/
static async findAllLibraryItemIds() {
const feeds = await this.findAll({
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map(f => f.entityId).filter(f => f) || []
}
/**
* Find feed where and return oldFeed
* @param {object} where sequelize where object
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findOneOld(where) {
if (!where) return null
const feedExpanded = await this.findOne({
where,
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/**
* Find feed and return oldFeed
* @param {string} id
* @returns {Promise<objects.Feed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) {
const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)
if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await sequelize.models.feedEpisode.create(feedEpisode)
}
}
}
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
const existingFeed = await this.findByPk(feedObj.id, {
include: sequelize.models.feedEpisode
})
if (!existingFeed) return false
let hasUpdates = false
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find(ep => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
} else {
let episodeHasUpdates = false
const oldFeedEpisodeCleaned = sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await feedEpisode.update(oldFeedEpisodeCleaned)
hasUpdates = true
}
}
}
let feedHasUpdates = false
for (const key in feedObj) {
let existingValue = existingFeed[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(existingValue, feedObj[key])) {
feedHasUpdates = true
}
}
if (feedHasUpdates) {
await existingFeed.update(feedObj)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldFeed) {
const oldFeedMeta = oldFeed.meta || {}
return {
id: oldFeed.id,
slug: oldFeed.slug,
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
}
}
getEntity(options) {
if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options)
}
}
Feed.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
slug: DataTypes.STRING,
entityType: DataTypes.STRING,
entityId: DataTypes.UUIDV4,
entityUpdatedAt: DataTypes.DATE,
serverAddress: DataTypes.STRING,
feedURL: DataTypes.STRING,
imageURL: DataTypes.STRING,
siteURL: DataTypes.STRING,
title: DataTypes.STRING,
description: DataTypes.TEXT,
author: DataTypes.STRING,
podcastType: DataTypes.STRING,
language: DataTypes.STRING,
ownerName: DataTypes.STRING,
ownerEmail: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
preventIndexing: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING
}, {
sequelize,
modelName: 'feed'
})
const { user, libraryItem, collection, series, playlist } = sequelize.models
user.hasMany(Feed)
Feed.belongsTo(user)
libraryItem.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'libraryItem'
}
})
Feed.belongsTo(libraryItem, { foreignKey: 'entityId', constraints: false })
collection.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'collection'
}
})
Feed.belongsTo(collection, { foreignKey: 'entityId', constraints: false })
series.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'series'
}
})
Feed.belongsTo(series, { foreignKey: 'entityId', constraints: false })
playlist.hasMany(Feed, {
foreignKey: 'entityId',
constraints: false,
scope: {
entityType: 'playlist'
}
})
Feed.belongsTo(playlist, { foreignKey: 'entityId', constraints: false })
Feed.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.entityType === 'libraryItem' && instance.libraryItem !== undefined) {
instance.entity = instance.libraryItem
instance.dataValues.entity = instance.dataValues.libraryItem
} else if (instance.entityType === 'collection' && instance.collection !== undefined) {
instance.entity = instance.collection
instance.dataValues.entity = instance.dataValues.collection
} else if (instance.entityType === 'series' && instance.series !== undefined) {
instance.entity = instance.series
instance.dataValues.entity = instance.dataValues.series
} else if (instance.entityType === 'playlist' && instance.playlist !== undefined) {
instance.entity = instance.playlist
instance.dataValues.entity = instance.dataValues.playlist
}
// To prevent mistakes:
delete instance.libraryItem
delete instance.dataValues.libraryItem
delete instance.collection
delete instance.dataValues.collection
delete instance.series
delete instance.dataValues.series
delete instance.playlist
delete instance.dataValues.playlist
}
})
return Feed
}

View file

@ -0,0 +1,82 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class FeedEpisode extends Model {
getOldEpisode() {
const enclosure = {
url: this.enclosureURL,
size: this.enclosureSize,
type: this.enclosureType
}
return {
id: this.id,
title: this.title,
description: this.description,
enclosure,
pubDate: this.pubDate,
link: this.siteURL,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
fullPath: this.filePath
}
}
static getFromOld(oldFeedEpisode) {
return {
id: oldFeedEpisode.id,
title: oldFeedEpisode.title,
author: oldFeedEpisode.author,
description: oldFeedEpisode.description,
siteURL: oldFeedEpisode.link,
enclosureURL: oldFeedEpisode.enclosure?.url || null,
enclosureType: oldFeedEpisode.enclosure?.type || null,
enclosureSize: oldFeedEpisode.enclosure?.size || null,
pubDate: oldFeedEpisode.pubDate,
season: oldFeedEpisode.season || null,
episode: oldFeedEpisode.episode || null,
episodeType: oldFeedEpisode.episodeType || null,
duration: oldFeedEpisode.duration,
filePath: oldFeedEpisode.fullPath,
explicit: !!oldFeedEpisode.explicit
}
}
}
FeedEpisode.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
author: DataTypes.STRING,
description: DataTypes.TEXT,
siteURL: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureType: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
pubDate: DataTypes.STRING,
season: DataTypes.STRING,
episode: DataTypes.STRING,
episodeType: DataTypes.STRING,
duration: DataTypes.FLOAT,
filePath: DataTypes.STRING,
explicit: DataTypes.BOOLEAN
}, {
sequelize,
modelName: 'feedEpisode'
})
const { feed } = sequelize.models
feed.hasMany(FeedEpisode, {
onDelete: 'CASCADE'
})
FeedEpisode.belongsTo(feed)
return FeedEpisode
}

218
server/models/Library.js Normal file
View file

@ -0,0 +1,218 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
const oldLibrary = require('../objects/Library')
module.exports = (sequelize) => {
class Library extends Model {
/**
* Get all old libraries
* @returns {Promise<oldLibrary[]>}
*/
static async getAllOldLibraries() {
const libraries = await this.findAll({
include: sequelize.models.libraryFolder,
order: [['displayOrder', 'ASC']]
})
return libraries.map(lib => this.getOldLibrary(lib))
}
/**
* Convert expanded Library to oldLibrary
* @param {Library} libraryExpanded
* @returns {Promise<oldLibrary>}
*/
static getOldLibrary(libraryExpanded) {
const folders = libraryExpanded.libraryFolders.map(folder => {
return {
id: folder.id,
fullPath: folder.path,
libraryId: folder.libraryId,
addedAt: folder.createdAt.valueOf()
}
})
return new oldLibrary({
id: libraryExpanded.id,
oldLibraryId: libraryExpanded.extraData?.oldLibraryId || null,
name: libraryExpanded.name,
folders,
displayOrder: libraryExpanded.displayOrder,
icon: libraryExpanded.icon,
mediaType: libraryExpanded.mediaType,
provider: libraryExpanded.provider,
settings: libraryExpanded.settings,
createdAt: libraryExpanded.createdAt.valueOf(),
lastUpdate: libraryExpanded.updatedAt.valueOf()
})
}
/**
* @param {object} oldLibrary
* @returns {Library|null}
*/
static async createFromOld(oldLibrary) {
const library = this.getFromOld(oldLibrary)
library.libraryFolders = oldLibrary.folders.map(folder => {
return {
id: folder.id,
path: folder.fullPath
}
})
return this.create(library, {
include: sequelize.models.libraryFolder
}).catch((error) => {
Logger.error(`[Library] Failed to create library ${library.id}`, error)
return null
})
}
/**
* Update library and library folders
* @param {object} oldLibrary
* @returns
*/
static async updateFromOld(oldLibrary) {
const existingLibrary = await this.findByPk(oldLibrary.id, {
include: sequelize.models.libraryFolder
})
if (!existingLibrary) {
Logger.error(`[Library] Failed to update library ${oldLibrary.id} - not found`)
return null
}
const library = this.getFromOld(oldLibrary)
const libraryFolders = oldLibrary.folders.map(folder => {
return {
id: folder.id,
path: folder.fullPath,
libraryId: library.id
}
})
for (const libraryFolder of libraryFolders) {
const existingLibraryFolder = existingLibrary.libraryFolders.find(lf => lf.id === libraryFolder.id)
if (!existingLibraryFolder) {
await sequelize.models.libraryFolder.create(libraryFolder)
} else if (existingLibraryFolder.path !== libraryFolder.path) {
await existingLibraryFolder.update({ path: libraryFolder.path })
}
}
const libraryFoldersRemoved = existingLibrary.libraryFolders.filter(lf => !libraryFolders.some(_lf => _lf.id === lf.id))
for (const existingLibraryFolder of libraryFoldersRemoved) {
await existingLibraryFolder.destroy()
}
return existingLibrary.update(library)
}
static getFromOld(oldLibrary) {
const extraData = {}
if (oldLibrary.oldLibraryId) {
extraData.oldLibraryId = oldLibrary.oldLibraryId
}
return {
id: oldLibrary.id,
name: oldLibrary.name,
displayOrder: oldLibrary.displayOrder,
icon: oldLibrary.icon || null,
mediaType: oldLibrary.mediaType || null,
provider: oldLibrary.provider,
settings: oldLibrary.settings?.toJSON() || {},
createdAt: oldLibrary.createdAt,
updatedAt: oldLibrary.lastUpdate,
extraData
}
}
/**
* Destroy library by id
* @param {string} libraryId
* @returns
*/
static removeById(libraryId) {
return this.destroy({
where: {
id: libraryId
}
})
}
/**
* Get all library ids
* @returns {Promise<string[]>} array of library ids
*/
static async getAllLibraryIds() {
const libraries = await this.findAll({
attributes: ['id', 'displayOrder'],
order: [['displayOrder', 'ASC']]
})
return libraries.map(l => l.id)
}
/**
* Find Library by primary key & return oldLibrary
* @param {string} libraryId
* @returns {Promise<oldLibrary|null>} Returns null if not found
*/
static async getOldById(libraryId) {
if (!libraryId) return null
const library = await this.findByPk(libraryId, {
include: sequelize.models.libraryFolder
})
if (!library) return null
return this.getOldLibrary(library)
}
/**
* Get the largest value in the displayOrder column
* Used for setting a new libraries display order
* @returns {Promise<number>}
*/
static getMaxDisplayOrder() {
return this.max('displayOrder') || 0
}
/**
* Updates displayOrder to be sequential
* Used after removing a library
*/
static async resetDisplayOrder() {
const libraries = await this.findAll({
order: [['displayOrder', 'ASC']]
})
for (let i = 0; i < libraries.length; i++) {
const library = libraries[i]
if (library.displayOrder !== i + 1) {
Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`)
await library.update({ displayOrder: i + 1 }).catch((error) => {
Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error)
})
}
}
}
}
Library.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
displayOrder: DataTypes.INTEGER,
icon: DataTypes.STRING,
mediaType: DataTypes.STRING,
provider: DataTypes.STRING,
lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING,
settings: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'library'
})
return Library
}

View file

@ -0,0 +1,36 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class LibraryFolder extends Model {
/**
* Gets all library folder path strings
* @returns {Promise<string[]>} array of library folder paths
*/
static async getAllLibraryFolderPaths() {
const libraryFolders = await this.findAll({
attributes: ['path']
})
return libraryFolders.map(l => l.path)
}
}
LibraryFolder.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
path: DataTypes.STRING
}, {
sequelize,
modelName: 'libraryFolder'
})
const { library } = sequelize.models
library.hasMany(LibraryFolder, {
onDelete: 'CASCADE'
})
LibraryFolder.belongsTo(library)
return LibraryFolder
}

View file

@ -0,0 +1,786 @@
const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
const oldLibraryItem = require('../objects/LibraryItem')
const libraryFilters = require('../utils/queries/libraryFilters')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class LibraryItem extends Model {
/**
* Loads all podcast episodes, all library items in chunks of 500, then maps them to old library items
* @todo this is a temporary solution until we can use the sqlite without loading all the library items on init
*
* @returns {Promise<objects.LibraryItem[]>} old library items
*/
static async loadAllLibraryItems() {
let start = Date.now()
Logger.info(`[LibraryItem] Loading podcast episodes...`)
const podcastEpisodes = await sequelize.models.podcastEpisode.findAll()
Logger.info(`[LibraryItem] Finished loading ${podcastEpisodes.length} podcast episodes in ${((Date.now() - start) / 1000).toFixed(2)}s`)
start = Date.now()
Logger.info(`[LibraryItem] Loading library items...`)
let libraryItems = await this.getAllOldLibraryItemsIncremental()
Logger.info(`[LibraryItem] Finished loading ${libraryItems.length} library items in ${((Date.now() - start) / 1000).toFixed(2)}s`)
// Map LibraryItem to old library item
libraryItems = libraryItems.map(li => {
if (li.mediaType === 'podcast') {
li.media.podcastEpisodes = podcastEpisodes.filter(pe => pe.podcastId === li.media.id)
}
return this.getOldLibraryItem(li)
})
return libraryItems
}
/**
* Loads all LibraryItem in batches of 500
* @todo temporary solution
*
* @param {Model<LibraryItem>[]} libraryItems
* @param {number} offset
* @returns {Promise<Model<LibraryItem>[]>}
*/
static async getAllOldLibraryItemsIncremental(libraryItems = [], offset = 0) {
const limit = 500
const rows = await this.getLibraryItemsIncrement(offset, limit)
libraryItems.push(...rows)
if (!rows.length || rows.length < limit) {
return libraryItems
}
Logger.info(`[LibraryItem] Loaded ${rows.length} library items. ${libraryItems.length} loaded so far.`)
return this.getAllOldLibraryItemsIncremental(libraryItems, offset + rows.length)
}
/**
* Gets library items partially expanded, not including podcast episodes
* @todo temporary solution
*
* @param {number} offset
* @param {number} limit
* @returns {Promise<Model<LibraryItem>[]>} LibraryItem
*/
static getLibraryItemsIncrement(offset, limit) {
return this.findAll({
benchmark: true,
logging: (sql, timeMs) => {
console.log(`[Query] Elapsed ${timeMs}ms.`)
},
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.author,
through: {
attributes: ['createdAt']
}
},
{
model: sequelize.models.series,
through: {
attributes: ['sequence', 'createdAt']
}
}
]
},
{
model: sequelize.models.podcast
}
],
order: [
['createdAt', 'ASC'],
// Ensure author & series stay in the same order
[sequelize.models.book, sequelize.models.author, sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[sequelize.models.book, sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
],
offset,
limit
})
}
/**
* Currently unused because this is too slow and uses too much mem
*
* @returns {Array<objects.LibraryItem>} old library items
*/
static async getAllOldLibraryItems() {
let libraryItems = await this.findAll({
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.author,
through: {
attributes: []
}
},
{
model: sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: sequelize.models.podcast,
include: [
{
model: sequelize.models.podcastEpisode
}
]
}
]
})
return libraryItems.map(ti => this.getOldLibraryItem(ti))
}
/**
* Convert an expanded LibraryItem into an old library item
*
* @param {Model<LibraryItem>} libraryItemExpanded
* @returns {oldLibraryItem}
*/
static getOldLibraryItem(libraryItemExpanded) {
let media = null
if (libraryItemExpanded.mediaType === 'book') {
media = sequelize.models.book.getOldBook(libraryItemExpanded)
} else if (libraryItemExpanded.mediaType === 'podcast') {
media = sequelize.models.podcast.getOldPodcast(libraryItemExpanded)
}
return new oldLibraryItem({
id: libraryItemExpanded.id,
ino: libraryItemExpanded.ino,
oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
libraryId: libraryItemExpanded.libraryId,
folderId: libraryItemExpanded.libraryFolderId,
path: libraryItemExpanded.path,
relPath: libraryItemExpanded.relPath,
isFile: libraryItemExpanded.isFile,
mtimeMs: libraryItemExpanded.mtime?.valueOf(),
ctimeMs: libraryItemExpanded.ctime?.valueOf(),
birthtimeMs: libraryItemExpanded.birthtime?.valueOf(),
addedAt: libraryItemExpanded.createdAt.valueOf(),
updatedAt: libraryItemExpanded.updatedAt.valueOf(),
lastScan: libraryItemExpanded.lastScan?.valueOf(),
scanVersion: libraryItemExpanded.lastScanVersion,
isMissing: !!libraryItemExpanded.isMissing,
isInvalid: !!libraryItemExpanded.isInvalid,
mediaType: libraryItemExpanded.mediaType,
media,
libraryFiles: libraryItemExpanded.libraryFiles
})
}
static async fullCreateFromOld(oldLibraryItem) {
const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem))
if (oldLibraryItem.mediaType === 'book') {
const bookObj = sequelize.models.book.getFromOld(oldLibraryItem.media)
bookObj.libraryItemId = newLibraryItem.id
const newBook = await sequelize.models.book.create(bookObj)
const oldBookAuthors = oldLibraryItem.media.metadata.authors || []
const oldBookSeriesAll = oldLibraryItem.media.metadata.series || []
for (const oldBookAuthor of oldBookAuthors) {
await sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id })
}
for (const oldSeries of oldBookSeriesAll) {
await sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence })
}
} else if (oldLibraryItem.mediaType === 'podcast') {
const podcastObj = sequelize.models.podcast.getFromOld(oldLibraryItem.media)
podcastObj.libraryItemId = newLibraryItem.id
const newPodcast = await sequelize.models.podcast.create(podcastObj)
const oldEpisodes = oldLibraryItem.media.episodes || []
for (const oldEpisode of oldEpisodes) {
const episodeObj = sequelize.models.podcastEpisode.getFromOld(oldEpisode)
episodeObj.libraryItemId = newLibraryItem.id
episodeObj.podcastId = newPodcast.id
await sequelize.models.podcastEpisode.create(episodeObj)
}
}
return newLibraryItem
}
static async fullUpdateFromOld(oldLibraryItem) {
const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, {
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.author,
through: {
attributes: []
}
},
{
model: sequelize.models.series,
through: {
attributes: ['id', 'sequence']
}
}
]
},
{
model: sequelize.models.podcast,
include: [
{
model: sequelize.models.podcastEpisode
}
]
}
]
})
if (!libraryItemExpanded) return false
let hasUpdates = false
// Check update Book/Podcast
if (libraryItemExpanded.media) {
let updatedMedia = null
if (libraryItemExpanded.mediaType === 'podcast') {
updatedMedia = sequelize.models.podcast.getFromOld(oldLibraryItem.media)
const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || []
const updatedPodcastEpisodes = oldLibraryItem.media.episodes || []
for (const existingPodcastEpisode of existingPodcastEpisodes) {
// Episode was removed
if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
await existingPodcastEpisode.destroy()
hasUpdates = true
}
}
for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id)
if (!existingEpisodeMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
await sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
hasUpdates = true
} else {
const updatedEpisodeCleaned = sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode)
let episodeHasUpdates = false
for (const key in updatedEpisodeCleaned) {
let existingValue = existingEpisodeMatch[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`)
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await existingEpisodeMatch.update(updatedEpisodeCleaned)
hasUpdates = true
}
}
}
} else if (libraryItemExpanded.mediaType === 'book') {
updatedMedia = sequelize.models.book.getFromOld(oldLibraryItem.media)
const existingAuthors = libraryItemExpanded.media.authors || []
const existingSeriesAll = libraryItemExpanded.media.series || []
const updatedAuthors = oldLibraryItem.media.metadata.authors || []
const updatedSeriesAll = oldLibraryItem.media.metadata.series || []
for (const existingAuthor of existingAuthors) {
// Author was removed from Book
if (!updatedAuthors.some(au => au.id === existingAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
await sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
hasUpdates = true
}
}
for (const updatedAuthor of updatedAuthors) {
// Author was added
if (!existingAuthors.some(au => au.id === updatedAuthor.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
await sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
hasUpdates = true
}
}
for (const existingSeries of existingSeriesAll) {
// Series was removed
if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
await sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
hasUpdates = true
}
}
for (const updatedSeries of updatedSeriesAll) {
// Series was added/updated
const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id)
if (!existingSeriesMatch) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
await sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
hasUpdates = true
} else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
hasUpdates = true
}
}
}
let hasMediaUpdates = false
for (const key in updatedMedia) {
let existingValue = libraryItemExpanded.media[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedMedia[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`)
hasMediaUpdates = true
}
}
if (hasMediaUpdates && updatedMedia) {
await libraryItemExpanded.media.update(updatedMedia)
hasUpdates = true
}
}
const updatedLibraryItem = this.getFromOld(oldLibraryItem)
let hasLibraryItemUpdates = false
for (const key in updatedLibraryItem) {
let existingValue = libraryItemExpanded[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`)
hasLibraryItemUpdates = true
}
}
if (hasLibraryItemUpdates) {
await libraryItemExpanded.update(updatedLibraryItem)
Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldLibraryItem) {
const extraData = {}
if (oldLibraryItem.oldLibraryItemId) {
extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
}
return {
id: oldLibraryItem.id,
ino: oldLibraryItem.ino,
path: oldLibraryItem.path,
relPath: oldLibraryItem.relPath,
mediaId: oldLibraryItem.media.id,
mediaType: oldLibraryItem.mediaType,
isFile: !!oldLibraryItem.isFile,
isMissing: !!oldLibraryItem.isMissing,
isInvalid: !!oldLibraryItem.isInvalid,
mtime: oldLibraryItem.mtimeMs,
ctime: oldLibraryItem.ctimeMs,
birthtime: oldLibraryItem.birthtimeMs,
size: oldLibraryItem.size,
lastScan: oldLibraryItem.lastScan,
lastScanVersion: oldLibraryItem.scanVersion,
libraryId: oldLibraryItem.libraryId,
libraryFolderId: oldLibraryItem.folderId,
libraryFiles: oldLibraryItem.libraryFiles?.map(lf => lf.toJSON()) || [],
extraData
}
}
static removeById(libraryItemId) {
return this.destroy({
where: {
id: libraryItemId
},
individualHooks: true
})
}
/**
* Get old library item by id
* @param {string} libraryItemId
* @returns {oldLibraryItem}
*/
static async getOldById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId, {
include: [
{
model: sequelize.models.book,
include: [
{
model: sequelize.models.author,
through: {
attributes: []
}
},
{
model: sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
},
{
model: sequelize.models.podcast,
include: [
{
model: sequelize.models.podcastEpisode
}
]
}
]
})
if (!libraryItem) return null
return this.getOldLibraryItem(libraryItem)
}
/**
* Get library items using filter and sort
* @param {oldLibrary} library
* @param {oldUser} user
* @param {object} options
* @returns {object} { libraryItems:oldLibraryItem[], count:number }
*/
static async getByFilterAndSort(library, user, options) {
let start = Date.now()
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, user, options)
Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`)
return {
libraryItems: libraryItems.map(li => {
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
if (li.collapsedSeries) {
oldLibraryItem.collapsedSeries = li.collapsedSeries
}
if (li.series) {
oldLibraryItem.media.metadata.series = li.series
}
if (li.rssFeed) {
oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
}
if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
}
if (li.numEpisodesIncomplete) {
oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
}
return oldLibraryItem
}),
count
}
}
/**
* Get home page data personalized shelves
* @param {oldLibrary} library
* @param {oldUser} user
* @param {string[]} include
* @param {number} limit
* @returns {object[]} array of shelf objects
*/
static async getPersonalizedShelves(library, user, include, limit) {
const fullStart = Date.now() // Used for testing load times
const shelves = []
// "Continue Listening" shelf
const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
if (itemsInProgressPayload.items.length) {
const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => li.media.isEBookOnly)
const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter(li => !li.media.isEBookOnly)
shelves.push({
id: 'continue-listening',
label: 'Continue Listening',
labelStringKey: 'LabelContinueListening',
type: library.isPodcast ? 'episode' : 'book',
entities: audioOnlyItemsInProgress,
total: itemsInProgressPayload.count
})
if (ebookOnlyItemsInProgress.length) {
// "Continue Reading" shelf
shelves.push({
id: 'continue-reading',
label: 'Continue Reading',
labelStringKey: 'LabelContinueReading',
type: 'book',
entities: ebookOnlyItemsInProgress,
total: itemsInProgressPayload.count
})
}
}
Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
let start = Date.now()
if (library.isBook) {
start = Date.now()
// "Continue Series" shelf
const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)
if (continueSeriesPayload.libraryItems.length) {
shelves.push({
id: 'continue-series',
label: 'Continue Series',
labelStringKey: 'LabelContinueSeries',
type: 'book',
entities: continueSeriesPayload.libraryItems,
total: continueSeriesPayload.count
})
}
Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
} else if (library.isPodcast) {
// "Newest Episodes" shelf
const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
if (newestEpisodesPayload.libraryItems.length) {
shelves.push({
id: 'newest-episodes',
label: 'Newest Episodes',
labelStringKey: 'LabelNewestEpisodes',
type: 'episode',
entities: newestEpisodesPayload.libraryItems,
total: newestEpisodesPayload.count
})
}
Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
}
start = Date.now()
// "Recently Added" shelf
const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)
if (mostRecentPayload.libraryItems.length) {
shelves.push({
id: 'recently-added',
label: 'Recently Added',
labelStringKey: 'LabelRecentlyAdded',
type: library.mediaType,
entities: mostRecentPayload.libraryItems,
total: mostRecentPayload.count
})
}
Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) {
start = Date.now()
// "Recent Series" shelf
const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)
if (seriesMostRecentPayload.series.length) {
shelves.push({
id: 'recent-series',
label: 'Recent Series',
labelStringKey: 'LabelRecentSeries',
type: 'series',
entities: seriesMostRecentPayload.series,
total: seriesMostRecentPayload.count
})
}
Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
start = Date.now()
// "Discover" shelf
const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)
if (discoverLibraryItemsPayload.libraryItems.length) {
shelves.push({
id: 'discover',
label: 'Discover',
labelStringKey: 'LabelDiscover',
type: library.mediaType,
entities: discoverLibraryItemsPayload.libraryItems,
total: discoverLibraryItemsPayload.count
})
}
Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
}
start = Date.now()
// "Listen Again" shelf
const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
if (mediaFinishedPayload.items.length) {
const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => li.media.isEBookOnly)
const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter(li => !li.media.isEBookOnly)
shelves.push({
id: 'listen-again',
label: 'Listen Again',
labelStringKey: 'LabelListenAgain',
type: library.isPodcast ? 'episode' : 'book',
entities: audioOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
// "Read Again" shelf
if (ebookOnlyItemsInProgress.length) {
shelves.push({
id: 'read-again',
label: 'Read Again',
labelStringKey: 'LabelReadAgain',
type: 'book',
entities: ebookOnlyItemsInProgress,
total: mediaFinishedPayload.count
})
}
}
Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
if (library.isBook) {
start = Date.now()
// "Newest Authors" shelf
const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit)
if (newestAuthorsPayload.authors.length) {
shelves.push({
id: 'newest-authors',
label: 'Newest Authors',
labelStringKey: 'LabelNewestAuthors',
type: 'authors',
entities: newestAuthorsPayload.authors,
total: newestAuthorsPayload.count
})
}
Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
}
Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
return shelves
}
/**
* Get book library items for author, optional use user permissions
* @param {oldAuthor} author
* @param {[oldUser]} user
* @returns {Promise<oldLibraryItem[]>}
*/
static async getForAuthor(author, user = null) {
const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined)
return libraryItems.map(li => this.getOldLibraryItem(li))
}
/**
* Get book library items in a collection
* @param {oldCollection} collection
* @returns {Promise<oldLibraryItem[]>}
*/
static async getForCollection(collection) {
const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection)
return libraryItems.map(li => this.getOldLibraryItem(li))
}
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
}
LibraryItem.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ino: DataTypes.STRING,
path: DataTypes.STRING,
relPath: DataTypes.STRING,
mediaId: DataTypes.UUIDV4,
mediaType: DataTypes.STRING,
isFile: DataTypes.BOOLEAN,
isMissing: DataTypes.BOOLEAN,
isInvalid: DataTypes.BOOLEAN,
mtime: DataTypes.DATE(6),
ctime: DataTypes.DATE(6),
birthtime: DataTypes.DATE(6),
size: DataTypes.BIGINT,
lastScan: DataTypes.DATE,
lastScanVersion: DataTypes.STRING,
libraryFiles: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'libraryItem',
indexes: [
{
fields: ['createdAt']
},
{
fields: ['mediaId']
},
{
fields: ['libraryId', 'mediaType']
},
{
fields: ['birthtime']
},
{
fields: ['mtime']
}
]
})
const { library, libraryFolder, book, podcast } = sequelize.models
library.hasMany(LibraryItem)
LibraryItem.belongsTo(library)
libraryFolder.hasMany(LibraryItem)
LibraryItem.belongsTo(libraryFolder)
book.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'book'
}
})
LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
podcast.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'podcast'
}
})
LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
LibraryItem.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaType === 'book' && instance.book !== undefined) {
instance.media = instance.book
instance.dataValues.media = instance.dataValues.book
} else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) {
instance.media = instance.podcast
instance.dataValues.media = instance.dataValues.podcast
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcast
delete instance.dataValues.podcast
}
})
LibraryItem.addHook('afterDestroy', async instance => {
if (!instance) return
const media = await instance.getMedia()
if (media) {
media.destroy()
}
})
return LibraryItem
}

View file

@ -0,0 +1,148 @@
const { DataTypes, Model } = require('sequelize')
/*
* Polymorphic association: https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/
* Book has many MediaProgress. PodcastEpisode has many MediaProgress.
*/
module.exports = (sequelize) => {
class MediaProgress extends Model {
getOldMediaProgress() {
const isPodcastEpisode = this.mediaItemType === 'podcastEpisode'
return {
id: this.id,
userId: this.userId,
libraryItemId: this.extraData?.libraryItemId || null,
episodeId: isPodcastEpisode ? this.mediaItemId : null,
mediaItemId: this.mediaItemId,
mediaItemType: this.mediaItemType,
duration: this.duration,
progress: this.extraData?.progress || 0,
currentTime: this.currentTime,
isFinished: !!this.isFinished,
hideFromContinueListening: !!this.hideFromContinueListening,
ebookLocation: this.ebookLocation,
ebookProgress: this.ebookProgress,
lastUpdate: this.updatedAt.valueOf(),
startedAt: this.createdAt.valueOf(),
finishedAt: this.finishedAt?.valueOf() || null
}
}
static upsertFromOld(oldMediaProgress) {
const mediaProgress = this.getFromOld(oldMediaProgress)
return this.upsert(mediaProgress)
}
static getFromOld(oldMediaProgress) {
return {
id: oldMediaProgress.id,
userId: oldMediaProgress.userId,
mediaItemId: oldMediaProgress.mediaItemId,
mediaItemType: oldMediaProgress.mediaItemType,
duration: oldMediaProgress.duration,
currentTime: oldMediaProgress.currentTime,
ebookLocation: oldMediaProgress.ebookLocation || null,
ebookProgress: oldMediaProgress.ebookProgress || null,
isFinished: !!oldMediaProgress.isFinished,
hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening,
finishedAt: oldMediaProgress.finishedAt,
createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate,
updatedAt: oldMediaProgress.lastUpdate,
extraData: {
libraryItemId: oldMediaProgress.libraryItemId,
progress: oldMediaProgress.progress
}
}
}
static removeById(mediaProgressId) {
return this.destroy({
where: {
id: mediaProgressId
}
})
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
}
MediaProgress.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
duration: DataTypes.FLOAT,
currentTime: DataTypes.FLOAT,
isFinished: DataTypes.BOOLEAN,
hideFromContinueListening: DataTypes.BOOLEAN,
ebookLocation: DataTypes.STRING,
ebookProgress: DataTypes.FLOAT,
finishedAt: DataTypes.DATE,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'mediaProgress',
indexes: [
{
fields: ['updatedAt']
}
]
})
const { book, podcastEpisode, user } = sequelize.models
book.hasMany(MediaProgress, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasMany(MediaProgress, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
MediaProgress.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
user.hasMany(MediaProgress, {
onDelete: 'CASCADE'
})
MediaProgress.belongsTo(user)
return MediaProgress
}

View file

@ -0,0 +1,198 @@
const { DataTypes, Model } = require('sequelize')
const oldPlaybackSession = require('../objects/PlaybackSession')
module.exports = (sequelize) => {
class PlaybackSession extends Model {
static async getOldPlaybackSessions(where = null) {
const playbackSessions = await this.findAll({
where,
include: [
{
model: sequelize.models.device
}
]
})
return playbackSessions.map(session => this.getOldPlaybackSession(session))
}
static async getById(sessionId) {
const playbackSession = await this.findByPk(sessionId, {
include: [
{
model: sequelize.models.device
}
]
})
if (!playbackSession) return null
return this.getOldPlaybackSession(playbackSession)
}
static getOldPlaybackSession(playbackSessionExpanded) {
const isPodcastEpisode = playbackSessionExpanded.mediaItemType === 'podcastEpisode'
return new oldPlaybackSession({
id: playbackSessionExpanded.id,
userId: playbackSessionExpanded.userId,
libraryId: playbackSessionExpanded.libraryId,
libraryItemId: playbackSessionExpanded.extraData?.libraryItemId || null,
bookId: isPodcastEpisode ? null : playbackSessionExpanded.mediaItemId,
episodeId: isPodcastEpisode ? playbackSessionExpanded.mediaItemId : null,
mediaType: isPodcastEpisode ? 'podcast' : 'book',
mediaMetadata: playbackSessionExpanded.mediaMetadata,
chapters: null,
displayTitle: playbackSessionExpanded.displayTitle,
displayAuthor: playbackSessionExpanded.displayAuthor,
coverPath: playbackSessionExpanded.coverPath,
duration: playbackSessionExpanded.duration,
playMethod: playbackSessionExpanded.playMethod,
mediaPlayer: playbackSessionExpanded.mediaPlayer,
deviceInfo: playbackSessionExpanded.device?.getOldDevice() || null,
serverVersion: playbackSessionExpanded.serverVersion,
date: playbackSessionExpanded.date,
dayOfWeek: playbackSessionExpanded.dayOfWeek,
timeListening: playbackSessionExpanded.timeListening,
startTime: playbackSessionExpanded.startTime,
currentTime: playbackSessionExpanded.currentTime,
startedAt: playbackSessionExpanded.createdAt.valueOf(),
updatedAt: playbackSessionExpanded.updatedAt.valueOf()
})
}
static removeById(sessionId) {
return this.destroy({
where: {
id: sessionId
}
})
}
static createFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.create(playbackSession)
}
static updateFromOld(oldPlaybackSession) {
const playbackSession = this.getFromOld(oldPlaybackSession)
return this.update(playbackSession, {
where: {
id: playbackSession.id
}
})
}
static getFromOld(oldPlaybackSession) {
return {
id: oldPlaybackSession.id,
mediaItemId: oldPlaybackSession.episodeId || oldPlaybackSession.bookId,
mediaItemType: oldPlaybackSession.episodeId ? 'podcastEpisode' : 'book',
libraryId: oldPlaybackSession.libraryId,
displayTitle: oldPlaybackSession.displayTitle,
displayAuthor: oldPlaybackSession.displayAuthor,
duration: oldPlaybackSession.duration,
playMethod: oldPlaybackSession.playMethod,
mediaPlayer: oldPlaybackSession.mediaPlayer,
startTime: oldPlaybackSession.startTime,
currentTime: oldPlaybackSession.currentTime,
serverVersion: oldPlaybackSession.serverVersion || null,
createdAt: oldPlaybackSession.startedAt,
updatedAt: oldPlaybackSession.updatedAt,
userId: oldPlaybackSession.userId,
deviceId: oldPlaybackSession.deviceInfo?.id || null,
timeListening: oldPlaybackSession.timeListening,
coverPath: oldPlaybackSession.coverPath,
mediaMetadata: oldPlaybackSession.mediaMetadata,
date: oldPlaybackSession.date,
dayOfWeek: oldPlaybackSession.dayOfWeek,
extraData: {
libraryItemId: oldPlaybackSession.libraryItemId
}
}
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
}
PlaybackSession.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
displayTitle: DataTypes.STRING,
displayAuthor: DataTypes.STRING,
duration: DataTypes.FLOAT,
playMethod: DataTypes.INTEGER,
mediaPlayer: DataTypes.STRING,
startTime: DataTypes.FLOAT,
currentTime: DataTypes.FLOAT,
serverVersion: DataTypes.STRING,
coverPath: DataTypes.STRING,
timeListening: DataTypes.INTEGER,
mediaMetadata: DataTypes.JSON,
date: DataTypes.STRING,
dayOfWeek: DataTypes.STRING,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'playbackSession'
})
const { book, podcastEpisode, user, device, library } = sequelize.models
user.hasMany(PlaybackSession)
PlaybackSession.belongsTo(user)
device.hasMany(PlaybackSession)
PlaybackSession.belongsTo(device)
library.hasMany(PlaybackSession)
PlaybackSession.belongsTo(library)
book.hasMany(PlaybackSession, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaybackSession.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(PlaybackSession, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
PlaybackSession.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaybackSession.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
return PlaybackSession
}

312
server/models/Playlist.js Normal file
View file

@ -0,0 +1,312 @@
const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger')
const oldPlaylist = require('../objects/Playlist')
const { areEquivalent } = require('../utils/index')
module.exports = (sequelize) => {
class Playlist extends Model {
static async getOldPlaylists() {
const playlists = await this.findAll({
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
}
static getOldPlaylist(playlistExpanded) {
const items = playlistExpanded.playlistMediaItems.map(pmi => {
const libraryItemId = pmi.mediaItem?.podcast?.libraryItem?.id || pmi.mediaItem?.libraryItem?.id || null
if (!libraryItemId) {
Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2))
return null
}
return {
episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '',
libraryItemId
}
}).filter(pmi => pmi)
return new oldPlaylist({
id: playlistExpanded.id,
libraryId: playlistExpanded.libraryId,
userId: playlistExpanded.userId,
name: playlistExpanded.name,
description: playlistExpanded.description,
items,
lastUpdate: playlistExpanded.updatedAt.valueOf(),
createdAt: playlistExpanded.createdAt.valueOf()
})
}
static createFromOld(oldPlaylist) {
const playlist = this.getFromOld(oldPlaylist)
return this.create(playlist)
}
static async fullUpdateFromOld(oldPlaylist, playlistMediaItems) {
const existingPlaylist = await this.findByPk(oldPlaylist.id, {
include: sequelize.models.playlistMediaItem
})
if (!existingPlaylist) return false
let hasUpdates = false
const playlist = this.getFromOld(oldPlaylist)
for (const pmi of playlistMediaItems) {
const existingPmi = existingPlaylist.playlistMediaItems.find(i => i.mediaItemId === pmi.mediaItemId)
if (!existingPmi) {
await sequelize.models.playlistMediaItem.create(pmi)
hasUpdates = true
} else if (existingPmi.order != pmi.order) {
await existingPmi.update({ order: pmi.order })
hasUpdates = true
}
}
for (const pmi of existingPlaylist.playlistMediaItems) {
// Pmi was removed
if (!playlistMediaItems.some(i => i.mediaItemId === pmi.mediaItemId)) {
await pmi.destroy()
hasUpdates = true
}
}
let hasPlaylistUpdates = false
for (const key in playlist) {
let existingValue = existingPlaylist[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(playlist[key], existingValue)) {
hasPlaylistUpdates = true
}
}
if (hasPlaylistUpdates) {
existingPlaylist.update(playlist)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldPlaylist) {
return {
id: oldPlaylist.id,
name: oldPlaylist.name,
description: oldPlaylist.description,
userId: oldPlaylist.userId,
libraryId: oldPlaylist.libraryId
}
}
static removeById(playlistId) {
return this.destroy({
where: {
id: playlistId
}
})
}
/**
* Get playlist by id
* @param {string} playlistId
* @returns {Promise<oldPlaylist|null>} returns null if not found
*/
static async getById(playlistId) {
if (!playlistId) return null
const playlist = await this.findByPk(playlistId, {
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
if (!playlist) return null
return this.getOldPlaylist(playlist)
}
/**
* Get playlists for user and optionally for library
* @param {string} userId
* @param {[string]} libraryId optional
* @returns {Promise<oldPlaylist[]>}
*/
static async getPlaylistsForUserAndLibrary(userId, libraryId = null) {
if (!userId && !libraryId) return []
const whereQuery = {}
if (userId) {
whereQuery.userId = userId
}
if (libraryId) {
whereQuery.libraryId = libraryId
}
const playlists = await this.findAll({
where: whereQuery,
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
},
order: [['playlistMediaItems', 'order', 'ASC']]
})
return playlists.map(p => this.getOldPlaylist(p))
}
/**
* Get number of playlists for a user and library
* @param {string} userId
* @param {string} libraryId
* @returns
*/
static async getNumPlaylistsForUserAndLibrary(userId, libraryId) {
return this.count({
where: {
userId,
libraryId
}
})
}
/**
* Get all playlists for mediaItemIds
* @param {string[]} mediaItemIds
* @returns {Promise<oldPlaylist[]>}
*/
static async getPlaylistsForMediaItemIds(mediaItemIds) {
if (!mediaItemIds?.length) return []
const playlistMediaItemsExpanded = await sequelize.models.playlistMediaItem.findAll({
where: {
mediaItemId: {
[Op.in]: mediaItemIds
}
},
include: [
{
model: sequelize.models.playlist,
include: {
model: sequelize.models.playlistMediaItem,
include: [
{
model: sequelize.models.book,
include: sequelize.models.libraryItem
},
{
model: sequelize.models.podcastEpisode,
include: {
model: sequelize.models.podcast,
include: sequelize.models.libraryItem
}
}
]
}
}
],
order: [['playlist', 'playlistMediaItems', 'order', 'ASC']]
})
return playlistMediaItemsExpanded.map(pmie => {
pmie.playlist.playlistMediaItems = pmie.playlist.playlistMediaItems.map(pmi => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
return this.getOldPlaylist(pmie.playlist)
})
}
}
Playlist.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'playlist'
})
const { library, user } = sequelize.models
library.hasMany(Playlist)
Playlist.belongsTo(library)
user.hasMany(Playlist)
Playlist.belongsTo(user)
Playlist.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.playlistMediaItems?.length) {
instance.playlistMediaItems = instance.playlistMediaItems.map(pmi => {
if (pmi.mediaItemType === 'book' && pmi.book !== undefined) {
pmi.mediaItem = pmi.book
pmi.dataValues.mediaItem = pmi.dataValues.book
} else if (pmi.mediaItemType === 'podcastEpisode' && pmi.podcastEpisode !== undefined) {
pmi.mediaItem = pmi.podcastEpisode
pmi.dataValues.mediaItem = pmi.dataValues.podcastEpisode
}
// To prevent mistakes:
delete pmi.book
delete pmi.dataValues.book
delete pmi.podcastEpisode
delete pmi.dataValues.podcastEpisode
return pmi
})
}
}
})
return Playlist
}

View file

@ -0,0 +1,84 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class PlaylistMediaItem extends Model {
static removeByIds(playlistId, mediaItemId) {
return this.destroy({
where: {
playlistId,
mediaItemId
}
})
}
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
}
PlaylistMediaItem.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
order: DataTypes.INTEGER
}, {
sequelize,
timestamps: true,
updatedAt: false,
modelName: 'playlistMediaItem'
})
const { book, podcastEpisode, playlist } = sequelize.models
book.hasMany(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
PlaylistMediaItem.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(PlaylistMediaItem, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
PlaylistMediaItem.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
PlaylistMediaItem.addHook('afterFind', findResult => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
playlist.hasMany(PlaylistMediaItem, {
onDelete: 'CASCADE'
})
PlaylistMediaItem.belongsTo(playlist)
return PlaylistMediaItem
}

100
server/models/Podcast.js Normal file
View file

@ -0,0 +1,100 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class Podcast extends Model {
static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
return {
id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id,
metadata: {
title: podcastExpanded.title,
author: podcastExpanded.author,
description: podcastExpanded.description,
releaseDate: podcastExpanded.releaseDate,
genres: podcastExpanded.genres,
feedUrl: podcastExpanded.feedURL,
imageUrl: podcastExpanded.imageURL,
itunesPageUrl: podcastExpanded.itunesPageURL,
itunesId: podcastExpanded.itunesId,
itunesArtistId: podcastExpanded.itunesArtistId,
explicit: podcastExpanded.explicit,
language: podcastExpanded.language,
type: podcastExpanded.podcastType
},
coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags,
episodes: podcastEpisodes || [],
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
}
}
static getFromOld(oldPodcast) {
const oldPodcastMetadata = oldPodcast.metadata
return {
id: oldPodcast.id,
title: oldPodcastMetadata.title,
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix,
author: oldPodcastMetadata.author,
releaseDate: oldPodcastMetadata.releaseDate,
feedURL: oldPodcastMetadata.feedUrl,
imageURL: oldPodcastMetadata.imageUrl,
description: oldPodcastMetadata.description,
itunesPageURL: oldPodcastMetadata.itunesPageUrl,
itunesId: oldPodcastMetadata.itunesId,
itunesArtistId: oldPodcastMetadata.itunesArtistId,
language: oldPodcastMetadata.language,
podcastType: oldPodcastMetadata.type,
explicit: !!oldPodcastMetadata.explicit,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes,
autoDownloadSchedule: oldPodcast.autoDownloadSchedule,
lastEpisodeCheck: oldPodcast.lastEpisodeCheck,
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep,
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
}
}
}
Podcast.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING,
author: DataTypes.STRING,
releaseDate: DataTypes.STRING,
feedURL: DataTypes.STRING,
imageURL: DataTypes.STRING,
description: DataTypes.TEXT,
itunesPageURL: DataTypes.STRING,
itunesId: DataTypes.STRING,
itunesArtistId: DataTypes.STRING,
language: DataTypes.STRING,
podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
autoDownloadEpisodes: DataTypes.BOOLEAN,
autoDownloadSchedule: DataTypes.STRING,
lastEpisodeCheck: DataTypes.DATE,
maxEpisodesToKeep: DataTypes.INTEGER,
maxNewEpisodesToDownload: DataTypes.INTEGER,
coverPath: DataTypes.STRING,
tags: DataTypes.JSON,
genres: DataTypes.JSON
}, {
sequelize,
modelName: 'podcast'
})
return Podcast
}

View file

@ -0,0 +1,102 @@
const { DataTypes, Model } = require('sequelize')
module.exports = (sequelize) => {
class PodcastEpisode extends Model {
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return {
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
}
}
static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode)
}
static getFromOld(oldEpisode) {
const extraData = {}
if (oldEpisode.oldEpisodeId) {
extraData.oldEpisodeId = oldEpisode.oldEpisodeId
}
return {
id: oldEpisode.id,
index: oldEpisode.index,
season: oldEpisode.season,
episode: oldEpisode.episode,
episodeType: oldEpisode.episodeType,
title: oldEpisode.title,
subtitle: oldEpisode.subtitle,
description: oldEpisode.description,
pubDate: oldEpisode.pubDate,
enclosureURL: oldEpisode.enclosure?.url || null,
enclosureSize: oldEpisode.enclosure?.length || null,
enclosureType: oldEpisode.enclosure?.type || null,
publishedAt: oldEpisode.publishedAt,
podcastId: oldEpisode.podcastId,
audioFile: oldEpisode.audioFile?.toJSON() || null,
chapters: oldEpisode.chapters,
extraData
}
}
}
PodcastEpisode.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
index: DataTypes.INTEGER,
season: DataTypes.STRING,
episode: DataTypes.STRING,
episodeType: DataTypes.STRING,
title: DataTypes.STRING,
subtitle: DataTypes.STRING(1000),
description: DataTypes.TEXT,
pubDate: DataTypes.STRING,
enclosureURL: DataTypes.STRING,
enclosureSize: DataTypes.BIGINT,
enclosureType: DataTypes.STRING,
publishedAt: DataTypes.DATE,
audioFile: DataTypes.JSON,
chapters: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'podcastEpisode'
})
const { podcast } = sequelize.models
podcast.hasMany(PodcastEpisode, {
onDelete: 'CASCADE'
})
PodcastEpisode.belongsTo(podcast)
return PodcastEpisode
}

82
server/models/Series.js Normal file
View file

@ -0,0 +1,82 @@
const { DataTypes, Model } = require('sequelize')
const oldSeries = require('../objects/entities/Series')
module.exports = (sequelize) => {
class Series extends Model {
static async getAllOldSeries() {
const series = await this.findAll()
return series.map(se => se.getOldSeries())
}
getOldSeries() {
return new oldSeries({
id: this.id,
name: this.name,
description: this.description,
libraryId: this.libraryId,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static updateFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.update(series, {
where: {
id: series.id
}
})
}
static createFromOld(oldSeries) {
const series = this.getFromOld(oldSeries)
return this.create(series)
}
static createBulkFromOld(oldSeriesObjs) {
const series = oldSeriesObjs.map(this.getFromOld)
return this.bulkCreate(series)
}
static getFromOld(oldSeries) {
return {
id: oldSeries.id,
name: oldSeries.name,
nameIgnorePrefix: oldSeries.nameIgnorePrefix,
description: oldSeries.description,
libraryId: oldSeries.libraryId
}
}
static removeById(seriesId) {
return this.destroy({
where: {
id: seriesId
}
})
}
}
Series.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: DataTypes.STRING,
nameIgnorePrefix: DataTypes.STRING,
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series'
})
const { library } = sequelize.models
library.hasMany(Series, {
onDelete: 'CASCADE'
})
Series.belongsTo(library)
return Series
}

45
server/models/Setting.js Normal file
View file

@ -0,0 +1,45 @@
const { DataTypes, Model } = require('sequelize')
const oldEmailSettings = require('../objects/settings/EmailSettings')
const oldServerSettings = require('../objects/settings/ServerSettings')
const oldNotificationSettings = require('../objects/settings/NotificationSettings')
module.exports = (sequelize) => {
class Setting extends Model {
static async getOldSettings() {
const settings = (await this.findAll()).map(se => se.value)
const emailSettingsJson = settings.find(se => se.id === 'email-settings')
const serverSettingsJson = settings.find(se => se.id === 'server-settings')
const notificationSettingsJson = settings.find(se => se.id === 'notification-settings')
return {
settings,
emailSettings: new oldEmailSettings(emailSettingsJson),
serverSettings: new oldServerSettings(serverSettingsJson),
notificationSettings: new oldNotificationSettings(notificationSettingsJson)
}
}
static updateSettingObj(setting) {
return this.upsert({
key: setting.id,
value: setting
})
}
}
Setting.init({
key: {
type: DataTypes.STRING,
primaryKey: true
},
value: DataTypes.JSON
}, {
sequelize,
modelName: 'setting'
})
return Setting
}

240
server/models/User.js Normal file
View file

@ -0,0 +1,240 @@
const uuidv4 = require("uuid").v4
const { DataTypes, Model, Op } = require('sequelize')
const Logger = require('../Logger')
const oldUser = require('../objects/user/User')
module.exports = (sequelize) => {
class User extends Model {
/**
* Get all oldUsers
* @returns {Promise<oldUser>}
*/
static async getOldUsers() {
const users = await this.findAll({
include: sequelize.models.mediaProgress
})
return users.map(u => this.getOldUser(u))
}
static getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
const librariesAccessible = userExpanded.permissions?.librariesAccessible || []
const itemTagsSelected = userExpanded.permissions?.itemTagsSelected || []
const permissions = userExpanded.permissions || {}
delete permissions.librariesAccessible
delete permissions.itemTagsSelected
return new oldUser({
id: userExpanded.id,
oldUserId: userExpanded.extraData?.oldUserId || null,
username: userExpanded.username,
pash: userExpanded.pash,
type: userExpanded.type,
token: userExpanded.token,
mediaProgress,
seriesHideFromContinueListening: userExpanded.extraData?.seriesHideFromContinueListening || [],
bookmarks: userExpanded.bookmarks,
isActive: userExpanded.isActive,
isLocked: userExpanded.isLocked,
lastSeen: userExpanded.lastSeen?.valueOf() || null,
createdAt: userExpanded.createdAt.valueOf(),
permissions,
librariesAccessible,
itemTagsSelected
})
}
static createFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.create(user)
}
static updateFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.update(user, {
where: {
id: user.id
}
}).then((result) => result[0] > 0).catch((error) => {
Logger.error(`[User] Failed to save user ${oldUser.id}`, error)
return false
})
}
static getFromOld(oldUser) {
return {
id: oldUser.id,
username: oldUser.username,
pash: oldUser.pash || null,
type: oldUser.type || null,
token: oldUser.token || null,
isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null,
extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
librariesAccessible: oldUser.librariesAccessible || [],
itemTagsSelected: oldUser.itemTagsSelected || []
},
bookmarks: oldUser.bookmarks
}
}
static removeById(userId) {
return this.destroy({
where: {
id: userId
}
})
}
/**
* Create root user
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {oldUser}
*/
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ userId, username })
const newRoot = new oldUser({
id: userId,
type: 'root',
username,
pash,
token,
isActive: true,
createdAt: Date.now()
})
await this.createFromOld(newRoot)
return newRoot
}
/**
* Get a user by id or by the old database id
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
* @param {string} userId
* @returns {Promise<oldUser|null>} null if not found
*/
static async getUserByIdOrOldId(userId) {
if (!userId) return null
const user = await this.findOne({
where: {
[Op.or]: [
{
id: userId
},
{
extraData: {
[Op.substring]: userId
}
}
]
},
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by username case insensitive
* @param {string} username
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByUsername(username) {
if (!username) return null
const user = await this.findOne({
where: {
username: {
[Op.like]: username
}
},
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by id
* @param {string} userId
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserById(userId) {
if (!userId) return null
const user = await this.findByPk(userId, {
include: sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get array of user id and username
* @returns {object[]} { id, username }
*/
static async getMinifiedUserObjects() {
const users = await this.findAll({
attributes: ['id', 'username']
})
return users.map(u => {
return {
id: u.id,
username: u.username
}
})
}
/**
* Return true if root user exists
* @returns {boolean}
*/
static async getHasRootUser() {
const count = await this.count({
where: {
type: 'root'
}
})
return count > 0
}
}
User.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
username: DataTypes.STRING,
email: DataTypes.STRING,
pash: DataTypes.STRING,
type: DataTypes.STRING,
token: DataTypes.STRING,
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
isLocked: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
lastSeen: DataTypes.DATE,
permissions: DataTypes.JSON,
bookmarks: DataTypes.JSON,
extraData: DataTypes.JSON
}, {
sequelize,
modelName: 'user'
})
return User
}

View file

@ -5,8 +5,8 @@ const version = require('../../package.json').version
class Backup {
constructor(data = null) {
this.id = null
this.key = null // Special key for pre-version checks
this.datePretty = null
this.backupMetadataCovers = null
this.backupDirPath = null
this.filename = null
@ -23,9 +23,9 @@ class Backup {
}
get detailsString() {
var details = []
const details = []
details.push(this.id)
details.push(this.backupMetadataCovers ? '1' : '0')
details.push(this.key)
details.push(this.createdAt)
details.push(this.serverVersion)
return details.join('\n')
@ -33,7 +33,9 @@ class Backup {
construct(data) {
this.id = data.details[0]
this.backupMetadataCovers = data.details[1] === '1'
this.key = data.details[1]
if (this.key == 1) this.key = null // v2.2.23 and below backups stored '1' here
this.createdAt = Number(data.details[2])
this.serverVersion = data.details[3] || null
@ -48,7 +50,7 @@ class Backup {
toJSON() {
return {
id: this.id,
backupMetadataCovers: this.backupMetadataCovers,
key: this.key,
backupDirPath: this.backupDirPath,
datePretty: this.datePretty,
fullPath: this.fullPath,
@ -60,13 +62,12 @@ class Backup {
}
}
setData(data) {
setData(backupDirPath) {
this.id = date.format(new Date(), 'YYYY-MM-DD[T]HHmm')
this.key = 'sqlite'
this.datePretty = date.format(new Date(), 'ddd, MMM D YYYY HH:mm')
this.backupMetadataCovers = data.backupMetadataCovers
this.backupDirPath = data.backupDirPath
this.backupDirPath = backupDirPath
this.filename = this.id + '.audiobookshelf'
this.path = Path.join('backups', this.filename)

View file

@ -1,10 +1,9 @@
const { getId } = require('../utils/index')
const uuidv4 = require("uuid").v4
class Collection {
constructor(collection) {
this.id = null
this.libraryId = null
this.userId = null
this.name = null
this.description = null
@ -25,7 +24,6 @@ class Collection {
return {
id: this.id,
libraryId: this.libraryId,
userId: this.userId,
name: this.name,
description: this.description,
cover: this.cover,
@ -60,7 +58,6 @@ class Collection {
construct(collection) {
this.id = collection.id
this.libraryId = collection.libraryId
this.userId = collection.userId
this.name = collection.name
this.description = collection.description || null
this.cover = collection.cover || null
@ -71,11 +68,10 @@ class Collection {
}
setData(data) {
if (!data.userId || !data.libraryId || !data.name) {
if (!data.libraryId || !data.name) {
return false
}
this.id = getId('col')
this.userId = data.userId
this.id = uuidv4()
this.libraryId = data.libraryId
this.name = data.name
this.description = data.description || null

View file

@ -1,5 +1,9 @@
const uuidv4 = require("uuid").v4
class DeviceInfo {
constructor(deviceInfo = null) {
this.id = null
this.userId = null
this.deviceId = null
this.ipAddress = null
@ -16,7 +20,8 @@ class DeviceInfo {
this.model = null
this.sdkVersion = null // Android Only
this.serverVersion = null
this.clientName = null
this.deviceName = null
if (deviceInfo) {
this.construct(deviceInfo)
@ -33,6 +38,8 @@ class DeviceInfo {
toJSON() {
const obj = {
id: this.id,
userId: this.userId,
deviceId: this.deviceId,
ipAddress: this.ipAddress,
browserName: this.browserName,
@ -44,7 +51,8 @@ class DeviceInfo {
manufacturer: this.manufacturer,
model: this.model,
sdkVersion: this.sdkVersion,
serverVersion: this.serverVersion
clientName: this.clientName,
deviceName: this.deviceName
}
for (const key in obj) {
if (obj[key] === null || obj[key] === undefined) {
@ -65,6 +73,7 @@ class DeviceInfo {
// When client doesn't send a device id
getTempDeviceId() {
const keys = [
this.userId,
this.browserName,
this.browserVersion,
this.osName,
@ -78,8 +87,10 @@ class DeviceInfo {
return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64')
}
setData(ip, ua, clientDeviceInfo, serverVersion) {
this.deviceId = clientDeviceInfo?.deviceId || null
setData(ip, ua, clientDeviceInfo, serverVersion, userId) {
this.id = uuidv4()
this.userId = userId
this.deviceId = clientDeviceInfo?.deviceId || this.id
this.ipAddress = ip || null
this.browserName = ua?.browser.name || null
@ -88,16 +99,54 @@ class DeviceInfo {
this.osVersion = ua?.os.version || null
this.deviceType = ua?.device.type || null
this.clientVersion = clientDeviceInfo?.clientVersion || null
this.clientVersion = clientDeviceInfo?.clientVersion || serverVersion
this.manufacturer = clientDeviceInfo?.manufacturer || null
this.model = clientDeviceInfo?.model || null
this.sdkVersion = clientDeviceInfo?.sdkVersion || null
this.serverVersion = serverVersion || null
this.clientName = clientDeviceInfo?.clientName || null
if (this.sdkVersion) {
if (!this.clientName) this.clientName = 'Abs Android'
this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`
} else if (this.model) {
if (!this.clientName) this.clientName = 'Abs iOS'
this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}`
} else if (this.osName && this.browserName) {
if (!this.clientName) this.clientName = 'Abs Web'
this.deviceName = `${this.osName} ${this.osVersion || 'N/A'} ${this.browserName}`
} else if (!this.clientName) {
this.clientName = 'Unknown'
}
if (!this.deviceId) {
this.deviceId = this.getTempDeviceId()
}
}
update(deviceInfo) {
const deviceInfoJson = deviceInfo.toJSON ? deviceInfo.toJSON() : deviceInfo
const existingDeviceInfoJson = this.toJSON()
let hasUpdates = false
for (const key in deviceInfoJson) {
if (['id', 'deviceId'].includes(key)) continue
if (deviceInfoJson[key] !== existingDeviceInfoJson[key]) {
this[key] = deviceInfoJson[key]
hasUpdates = true
}
}
for (const key in existingDeviceInfoJson) {
if (['id', 'deviceId'].includes(key)) continue
if (existingDeviceInfoJson[key] && !deviceInfoJson[key]) {
this[key] = null
hasUpdates = true
}
}
return hasUpdates
}
}
module.exports = DeviceInfo

View file

@ -1,3 +1,4 @@
const uuidv4 = require("uuid").v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
const RSS = require('../libs/rss')
@ -39,6 +40,7 @@ class Feed {
this.userId = feed.userId
this.entityType = feed.entityType
this.entityId = feed.entityId
this.entityUpdatedAt = feed.entityUpdatedAt
this.coverPath = feed.coverPath
this.serverAddress = feed.serverAddress
this.feedUrl = feed.feedUrl
@ -77,7 +79,6 @@ class Feed {
getEpisodePath(id) {
var episode = this.episodes.find(ep => ep.id === id)
console.log('getEpisodePath=', id, episode)
if (!episode) return null
return episode.fullPath
}
@ -90,7 +91,7 @@ class Feed {
const feedUrl = `${serverAddress}/feed/${slug}`
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.id = slug
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'libraryItem'
@ -179,7 +180,7 @@ class Feed {
const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath)
this.id = slug
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'collection'
@ -253,7 +254,7 @@ class Feed {
const libraryId = itemsWithTracks[0].libraryId
const firstItemWithCover = itemsWithTracks.find(li => li.media.coverPath)
this.id = slug
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'series'

View file

@ -1,4 +1,4 @@
const Path = require('path')
const uuidv4 = require("uuid").v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
@ -98,13 +98,11 @@ class FeedEpisode {
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order
let episodeId = String(audioTrack.index)
let episodeId = uuidv4()
// Additional offset can be used for collections/series
if (additionalOffset !== null && !isNaN(additionalOffset)) {
timeOffset += Number(additionalOffset) * 1000
episodeId = String(additionalOffset) + '-' + episodeId
}
// e.g. Track 1 will have a pub date before Track 2

View file

@ -1,4 +1,4 @@
const { getId } = require("../utils")
const uuidv4 = require("uuid").v4
class Folder {
constructor(folder = null) {
@ -29,7 +29,7 @@ class Folder {
}
setData(data) {
this.id = data.id ? data.id : getId('fol')
this.id = data.id || uuidv4()
this.fullPath = data.fullPath
this.libraryId = data.libraryId
this.addedAt = Date.now()

View file

@ -1,11 +1,12 @@
const uuidv4 = require("uuid").v4
const Folder = require('./Folder')
const LibrarySettings = require('./settings/LibrarySettings')
const { getId } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')
class Library {
constructor(library = null) {
this.id = null
this.oldLibraryId = null // TODO: Temp
this.name = null
this.folders = []
this.displayOrder = 1
@ -33,9 +34,13 @@ class Library {
get isMusic() {
return this.mediaType === 'music'
}
get isBook() {
return this.mediaType === 'book'
}
construct(library) {
this.id = library.id
this.oldLibraryId = library.oldLibraryId
this.name = library.name
this.folders = (library.folders || []).map(f => new Folder(f))
this.displayOrder = library.displayOrder || 1
@ -71,6 +76,7 @@ class Library {
toJSON() {
return {
id: this.id,
oldLibraryId: this.oldLibraryId,
name: this.name,
folders: (this.folders || []).map(f => f.toJSON()),
displayOrder: this.displayOrder,
@ -84,7 +90,7 @@ class Library {
}
setData(data) {
this.id = data.id ? data.id : getId('lib')
this.id = data.id || uuidv4()
this.name = data.name
if (data.folder) {
this.folders = [

View file

@ -1,20 +1,22 @@
const uuidv4 = require("uuid").v4
const fs = require('../libs/fsExtra')
const Path = require('path')
const { version } = require('../../package.json')
const Logger = require('../Logger')
const abmetadataGenerator = require('../utils/abmetadataGenerator')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./mediaTypes/Book')
const Podcast = require('./mediaTypes/Podcast')
const Video = require('./mediaTypes/Video')
const Music = require('./mediaTypes/Music')
const { areEquivalent, copyValue, getId, cleanStringForSearch } = require('../utils/index')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index')
const { filePathToPOSIX } = require('../utils/fileUtils')
class LibraryItem {
constructor(libraryItem = null) {
this.id = null
this.ino = null // Inode
this.oldLibraryItemId = null
this.libraryId = null
this.folderId = null
@ -51,6 +53,7 @@ class LibraryItem {
construct(libraryItem) {
this.id = libraryItem.id
this.ino = libraryItem.ino || null
this.oldLibraryItemId = libraryItem.oldLibraryItemId
this.libraryId = libraryItem.libraryId
this.folderId = libraryItem.folderId
this.path = libraryItem.path
@ -80,12 +83,23 @@ class LibraryItem {
this.media.libraryItemId = this.id
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
// Migration for v2.2.23 to set ebook library files as supplementary
if (this.isBook && this.media.ebookFile) {
for (const libraryFile of this.libraryFiles) {
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
}
}
}
}
toJSON() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
@ -110,6 +124,7 @@ class LibraryItem {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
@ -134,6 +149,7 @@ class LibraryItem {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.oldLibraryItemId,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
@ -181,7 +197,7 @@ class LibraryItem {
// Data comes from scandir library item data
setData(libraryMediaType, payload) {
this.id = getId('li')
this.id = uuidv4()
this.mediaType = libraryMediaType
if (libraryMediaType === 'video') {
this.media = new Video()
@ -192,6 +208,7 @@ class LibraryItem {
} else if (libraryMediaType === 'music') {
this.media = new Music()
}
this.media.id = uuidv4()
this.media.libraryItemId = this.id
for (const key in payload) {
@ -432,21 +449,41 @@ class LibraryItem {
}
// Set metadata from files
async syncFiles(preferOpfMetadata) {
async syncFiles(preferOpfMetadata, librarySettings) {
let hasUpdated = false
if (this.mediaType === 'book') {
// Add/update ebook file (ebooks that were removed are removed in checkScanData)
this.libraryFiles.forEach((lf) => {
if (lf.fileType === 'ebook') {
if (!this.media.ebookFile) {
this.media.setEbookFile(lf)
hasUpdated = true
} else if (this.media.ebookFile.ino == lf.ino && this.media.ebookFile.updateFromLibraryFile(lf)) { // Update existing ebookFile
hasUpdated = true
}
if (this.isBook) {
// Add/update ebook files (ebooks that were removed are removed in checkScanData)
if (librarySettings.audiobooksOnly) {
hasUpdated = this.media.ebookFile
if (hasUpdated) {
// If library was set to audiobooks only then set primary ebook as supplementary
Logger.info(`[LibraryItem] Library is audiobooks only so setting ebook "${this.media.ebookFile.metadata.filename}" as supplementary`)
}
})
this.setPrimaryEbook(null)
} else if (this.media.ebookFile) {
const matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === this.media.ebookFile.ino)
if (matchingLibraryFile && this.media.ebookFile.updateFromLibraryFile(matchingLibraryFile)) {
hasUpdated = true
}
// Set any other ebook files as supplementary
const suppEbookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary && this.media.ebookFile.ino !== lf.ino)
if (suppEbookLibraryFiles.length) {
for (const libraryFile of suppEbookLibraryFiles) {
libraryFile.isSupplementary = true
}
hasUpdated = true
}
} else {
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile && !lf.isSupplementary)
// Prefer epub ebook then fallback to first other ebook file
const ebookLibraryFile = ebookLibraryFiles.find(lf => lf.metadata.format === 'epub') || ebookLibraryFiles[0]
if (ebookLibraryFile) {
this.setPrimaryEbook(ebookLibraryFile)
hasUpdated = true
}
}
}
// Set cover image if not set
@ -486,7 +523,10 @@ class LibraryItem {
return this.media.getDirectPlayTracklist(episodeId)
}
// Saves metadata.abs file
/**
* Save metadata.json/metadata.abs file
* @returns {boolean} true if saved
*/
async saveMetadata() {
if (this.mediaType === 'video' || this.mediaType === 'music') return
@ -519,6 +559,7 @@ class LibraryItem {
await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
this.libraryFiles.push(newLibraryFile)
}
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
return true
}).catch((error) => {
@ -562,5 +603,20 @@ class LibraryItem {
}
return false
}
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
* all ebook library files that are not primary are marked as supplementary
*
* @param {LibraryFile} [libraryFile]
*/
setPrimaryEbook(ebookLibraryFile = null) {
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile)
for (const libraryFile of ebookLibraryFiles) {
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
}
this.media.setEbookFile(ebookLibraryFile)
}
}
module.exports = LibraryItem

View file

@ -1,4 +1,4 @@
const { getId } = require('../utils/index')
const uuidv4 = require("uuid").v4
class Notification {
constructor(notification = null) {
@ -57,7 +57,7 @@ class Notification {
}
setData(payload) {
this.id = getId('noti')
this.id = uuidv4()
this.libraryId = payload.libraryId || null
this.eventName = payload.eventName
this.urls = payload.urls

View file

@ -1,6 +1,6 @@
const date = require('../libs/dateAndTime')
const { getId } = require('../utils/index')
const { PlayMethod } = require('../utils/constants')
const uuidv4 = require("uuid").v4
const serverVersion = require('../../package.json').version
const BookMetadata = require('./metadata/BookMetadata')
const PodcastMetadata = require('./metadata/PodcastMetadata')
const DeviceInfo = require('./DeviceInfo')
@ -12,6 +12,7 @@ class PlaybackSession {
this.userId = null
this.libraryId = null
this.libraryItemId = null
this.bookId = null
this.episodeId = null
this.mediaType = null
@ -25,6 +26,7 @@ class PlaybackSession {
this.playMethod = null
this.mediaPlayer = null
this.deviceInfo = null
this.serverVersion = null
this.date = null
this.dayOfWeek = null
@ -53,6 +55,7 @@ class PlaybackSession {
userId: this.userId,
libraryId: this.libraryId,
libraryItemId: this.libraryItemId,
bookId: this.bookId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
@ -64,6 +67,7 @@ class PlaybackSession {
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo?.toJSON() || null,
serverVersion: this.serverVersion,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@ -80,6 +84,7 @@ class PlaybackSession {
userId: this.userId,
libraryId: this.libraryId,
libraryItemId: this.libraryItemId,
bookId: this.bookId,
episodeId: this.episodeId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata?.toJSON() || null,
@ -91,6 +96,7 @@ class PlaybackSession {
playMethod: this.playMethod,
mediaPlayer: this.mediaPlayer,
deviceInfo: this.deviceInfo?.toJSON() || null,
serverVersion: this.serverVersion,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
@ -109,12 +115,31 @@ class PlaybackSession {
this.userId = session.userId
this.libraryId = session.libraryId || null
this.libraryItemId = session.libraryItemId
this.bookId = session.bookId || null
this.episodeId = session.episodeId
this.mediaType = session.mediaType
this.duration = session.duration
this.playMethod = session.playMethod
this.mediaPlayer = session.mediaPlayer || null
this.deviceInfo = new DeviceInfo(session.deviceInfo)
// Temp do not store old IDs
if (this.libraryId?.startsWith('lib_')) {
this.libraryId = null
}
if (this.libraryItemId?.startsWith('li_') || this.libraryItemId?.startsWith('local_')) {
this.libraryItemId = null
}
if (this.episodeId?.startsWith('ep_') || this.episodeId?.startsWith('local_')) {
this.episodeId = null
}
if (session.deviceInfo instanceof DeviceInfo) {
this.deviceInfo = new DeviceInfo(session.deviceInfo.toJSON())
} else {
this.deviceInfo = new DeviceInfo(session.deviceInfo)
}
this.serverVersion = session.serverVersion
this.chapters = session.chapters || []
this.mediaMetadata = null
@ -152,7 +177,7 @@ class PlaybackSession {
}
get deviceId() {
return this.deviceInfo?.deviceId
return this.deviceInfo?.id
}
get deviceDescription() {
@ -170,10 +195,11 @@ class PlaybackSession {
}
setData(libraryItem, user, mediaPlayer, deviceInfo, startTime, episodeId = null) {
this.id = getId('play')
this.id = uuidv4()
this.userId = user.id
this.libraryId = libraryItem.libraryId
this.libraryItemId = libraryItem.id
this.bookId = episodeId ? null : libraryItem.media.id
this.episodeId = episodeId
this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone()
@ -190,6 +216,7 @@ class PlaybackSession {
this.mediaPlayer = mediaPlayer
this.deviceInfo = deviceInfo || new DeviceInfo()
this.serverVersion = serverVersion
this.timeListening = 0
this.startTime = startTime

View file

@ -1,5 +1,4 @@
const Logger = require('../Logger')
const { getId } = require('../utils/index')
const uuidv4 = require("uuid").v4
class Playlist {
constructor(playlist) {
@ -88,7 +87,7 @@ class Playlist {
if (!data.userId || !data.libraryId || !data.name) {
return false
}
this.id = getId('pl')
this.id = uuidv4()
this.userId = data.userId
this.libraryId = data.libraryId
this.name = data.name

View file

@ -1,5 +1,5 @@
const Path = require('path')
const { getId } = require('../utils/index')
const uuidv4 = require("uuid").v4
const { sanitizeFilename } = require('../utils/fileUtils')
const globals = require('../utils/globals')
@ -15,6 +15,8 @@ class PodcastEpisodeDownload {
this.isFinished = false
this.failed = false
this.appendEpisodeId = false
this.startedAt = null
this.createdAt = null
this.finishedAt = null
@ -29,6 +31,7 @@ class PodcastEpisodeDownload {
libraryId: this.libraryId || null,
isFinished: this.isFinished,
failed: this.failed,
appendEpisodeId: this.appendEpisodeId,
startedAt: this.startedAt,
createdAt: this.createdAt,
finishedAt: this.finishedAt,
@ -52,7 +55,9 @@ class PodcastEpisodeDownload {
}
get targetFilename() {
return sanitizeFilename(`${this.podcastEpisode.title}.${this.fileExtension}`)
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : ''
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename)
}
get targetPath() {
return Path.join(this.libraryItem.path, this.targetFilename)
@ -65,7 +70,7 @@ class PodcastEpisodeDownload {
}
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = getId('epdl')
this.id = uuidv4()
this.podcastEpisode = podcastEpisode
const url = podcastEpisode.enclosure.url

View file

@ -10,7 +10,7 @@ const Ffmpeg = require('../libs/fluentFfmpeg')
const { secondsToTimestamp } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const { AudioMimeType } = require('../utils/constants')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
const hlsPlaylistGenerator = require('../utils/generators/hlsPlaylistGenerator')
const AudioTrack = require('./files/AudioTrack')
class Stream extends EventEmitter {
@ -83,7 +83,8 @@ class Stream extends EventEmitter {
AudioMimeType.AIFF,
AudioMimeType.WEBM,
AudioMimeType.WEBMA,
AudioMimeType.AWB
AudioMimeType.AWB,
AudioMimeType.CAF
]
}
get codecsToForceAAC() {

View file

@ -1,4 +1,4 @@
const { getId } = require('../utils/index')
const uuidv4 = require("uuid").v4
class Task {
constructor() {
@ -9,6 +9,7 @@ class Task {
this.title = null
this.description = null
this.error = null
this.showSuccess = false // If true client side should keep the task visible after success
this.isFailed = false
this.isFinished = false
@ -25,6 +26,7 @@ class Task {
title: this.title,
description: this.description,
error: this.error,
showSuccess: this.showSuccess,
isFailed: this.isFailed,
isFinished: this.isFinished,
startedAt: this.startedAt,
@ -32,12 +34,13 @@ class Task {
}
}
setData(action, title, description, data = {}) {
this.id = getId(action)
setData(action, title, description, showSuccess, data = {}) {
this.id = uuidv4()
this.action = action
this.data = { ...data }
this.title = title
this.description = description
this.showSuccess = showSuccess
this.startedAt = Date.now()
}
@ -48,7 +51,10 @@ class Task {
this.setFinished()
}
setFinished() {
setFinished(newDescription = null) {
if (newDescription) {
this.description = newDescription
}
this.isFinished = true
this.finishedAt = Date.now()
}

View file

@ -1,6 +1,6 @@
const Logger = require('../../Logger')
const { getId } = require('../../utils/index')
const { checkNamesAreEqual } = require('../../utils/parsers/parseNameString')
const uuidv4 = require("uuid").v4
const { checkNamesAreEqual, nameToLastFirst } = require('../../utils/parsers/parseNameString')
class Author {
constructor(author) {
@ -11,6 +11,7 @@ class Author {
this.imagePath = null
this.addedAt = null
this.updatedAt = null
this.libraryId = null
if (author) {
this.construct(author)
@ -25,6 +26,12 @@ class Author {
this.imagePath = author.imagePath
this.addedAt = author.addedAt
this.updatedAt = author.updatedAt
this.libraryId = author.libraryId
}
get lastFirst() {
if (!this.name) return ''
return nameToLastFirst(this.name)
}
toJSON() {
@ -35,7 +42,8 @@ class Author {
description: this.description,
imagePath: this.imagePath,
addedAt: this.addedAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
libraryId: this.libraryId
}
}
@ -52,14 +60,18 @@ class Author {
}
}
setData(data) {
this.id = getId('aut')
this.name = data.name
setData(data, libraryId) {
this.id = uuidv4()
if (!data.name) {
Logger.error(`[Author] setData: Setting author data without a name`, data)
}
this.name = data.name || ''
this.description = data.description || null
this.asin = data.asin || null
this.imagePath = data.imagePath || null
this.addedAt = Date.now()
this.updatedAt = Date.now()
this.libraryId = libraryId
}
update(payload) {

View file

@ -1,13 +1,16 @@
const uuidv4 = require("uuid").v4
const Path = require('path')
const Logger = require('../../Logger')
const { getId, cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
const { cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
class PodcastEpisode {
constructor(episode) {
this.libraryItemId = null
this.podcastId = null
this.id = null
this.oldEpisodeId = null
this.index = null
this.season = null
@ -32,7 +35,9 @@ class PodcastEpisode {
construct(episode) {
this.libraryItemId = episode.libraryItemId
this.podcastId = episode.podcastId
this.id = episode.id
this.oldEpisodeId = episode.oldEpisodeId
this.index = episode.index
this.season = episode.season
this.episode = episode.episode
@ -54,7 +59,9 @@ class PodcastEpisode {
toJSON() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
@ -75,7 +82,9 @@ class PodcastEpisode {
toJSONExpanded() {
return {
libraryItemId: this.libraryItemId,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.oldEpisodeId,
index: this.index,
season: this.season,
episode: this.episode,
@ -109,7 +118,7 @@ class PodcastEpisode {
}
get size() { return this.audioFile.metadata.size }
get enclosureUrl() {
return this.enclosure ? this.enclosure.url : null
return this.enclosure?.url || null
}
get pubYear() {
if (!this.publishedAt) return null
@ -117,7 +126,7 @@ class PodcastEpisode {
}
setData(data, index = 1) {
this.id = getId('ep')
this.id = uuidv4()
this.index = index
this.title = data.title
this.subtitle = data.subtitle || ''
@ -133,7 +142,7 @@ class PodcastEpisode {
}
setDataFromAudioFile(audioFile, index) {
this.id = getId('ep')
this.id = uuidv4()
this.audioFile = audioFile
this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
this.index = index
@ -148,8 +157,13 @@ class PodcastEpisode {
update(payload) {
let hasUpdates = false
for (const key in this.toJSON()) {
if (payload[key] != undefined && !areEquivalent(payload[key], this[key])) {
this[key] = copyValue(payload[key])
let newValue = payload[key]
if (newValue === "") newValue = null
let existingValue = this[key]
if (existingValue === "") existingValue = null
if (newValue != undefined && !areEquivalent(newValue, existingValue)) {
this[key] = copyValue(newValue)
hasUpdates = true
}
}

View file

@ -1,4 +1,5 @@
const { getId } = require('../../utils/index')
const uuidv4 = require("uuid").v4
const { getTitleIgnorePrefix } = require('../../utils/index')
class Series {
constructor(series) {
@ -7,6 +8,7 @@ class Series {
this.description = null
this.addedAt = null
this.updatedAt = null
this.libraryId = null
if (series) {
this.construct(series)
@ -19,6 +21,12 @@ class Series {
this.description = series.description || null
this.addedAt = series.addedAt
this.updatedAt = series.updatedAt
this.libraryId = series.libraryId
}
get nameIgnorePrefix() {
if (!this.name) return ''
return getTitleIgnorePrefix(this.name)
}
toJSON() {
@ -27,7 +35,8 @@ class Series {
name: this.name,
description: this.description,
addedAt: this.addedAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
libraryId: this.libraryId
}
}
@ -39,12 +48,13 @@ class Series {
}
}
setData(data) {
this.id = getId('ser')
setData(data, libraryId) {
this.id = uuidv4()
this.name = data.name
this.description = data.description || null
this.addedAt = Date.now()
this.updatedAt = Date.now()
this.libraryId = libraryId
}
update(series) {

View file

@ -1,6 +1,3 @@
const Path = require('path')
const { encodeUriPath } = require('../../utils/fileUtils')
class AudioTrack {
constructor() {
this.index = null
@ -22,7 +19,7 @@ class AudioTrack {
contentUrl: this.contentUrl,
mimeType: this.mimeType,
codec: this.codec,
metadata: this.metadata ? this.metadata.toJSON() : null
metadata: this.metadata?.toJSON() || null
}
}
@ -31,7 +28,8 @@ class AudioTrack {
this.startOffset = startOffset
this.duration = audioFile.duration
this.title = audioFile.metadata.filename || ''
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(audioFile.metadata.relPath))
this.contentUrl = `${global.RouterBasePath}/api/items/${itemId}/file/${audioFile.ino}`
this.mimeType = audioFile.mimeType
this.codec = audioFile.codec || null
this.metadata = audioFile.metadata.clone()

View file

@ -7,6 +7,7 @@ class LibraryFile {
constructor(file) {
this.ino = null
this.metadata = null
this.isSupplementary = null
this.addedAt = null
this.updatedAt = null
@ -18,6 +19,7 @@ class LibraryFile {
construct(file) {
this.ino = file.ino
this.metadata = new FileMetadata(file.metadata)
this.isSupplementary = file.isSupplementary === undefined ? null : file.isSupplementary
this.addedAt = file.addedAt
this.updatedAt = file.updatedAt
}
@ -26,6 +28,7 @@ class LibraryFile {
return {
ino: this.ino,
metadata: this.metadata.toJSON(),
isSupplementary: this.isSupplementary,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
fileType: this.fileType
@ -50,6 +53,10 @@ class LibraryFile {
return this.fileType === 'audio' || this.fileType === 'ebook' || this.fileType === 'video'
}
get isEBookFile() {
return this.fileType === 'ebook'
}
get isOPFFile() {
return this.metadata.ext === '.opf'
}

View file

@ -28,7 +28,7 @@ class VideoTrack {
this.index = videoFile.index
this.duration = videoFile.duration
this.title = videoFile.metadata.filename || ''
this.contentUrl = Path.join(`${global.RouterBasePath}/s/item/${itemId}`, encodeUriPath(videoFile.metadata.relPath))
this.contentUrl = Path.join(`${global.RouterBasePath}/api/items/${itemId}/file/${videoFile.ino}`, encodeUriPath(videoFile.metadata.relPath))
this.mimeType = videoFile.mimeType
this.codec = videoFile.codec
this.metadata = videoFile.metadata.clone()

View file

@ -4,7 +4,7 @@ const BookMetadata = require('../metadata/BookMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata')
const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
@ -12,6 +12,7 @@ const EBookFile = require('../files/EBookFile')
class Book {
constructor(book) {
this.id = null
this.libraryItemId = null
this.metadata = null
@ -32,6 +33,7 @@ class Book {
}
construct(book) {
this.id = book.id
this.libraryItemId = book.libraryItemId
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
@ -46,6 +48,7 @@ class Book {
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
@ -59,6 +62,7 @@ class Book {
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
@ -75,6 +79,7 @@ class Book {
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
@ -142,6 +147,9 @@ class Book {
get numTracks() {
return this.tracks.length
}
get isEBookOnly() {
return this.ebookFile && !this.numTracks
}
update(payload) {
const json = this.toJSON()
@ -195,6 +203,7 @@ class Book {
this.coverPath = coverPath
return true
}
removeFileWithInode(inode) {
if (this.audioFiles.some(af => af.ino === inode)) {
this.audioFiles = this.audioFiles.filter(af => af.ino !== inode)
@ -207,8 +216,13 @@ class Book {
return false
}
/**
* Get audio file or ebook file from inode
* @param {string} inode
* @returns {(AudioFile|EBookFile|null)}
*/
findFileWithInode(inode) {
var audioFile = this.audioFiles.find(af => af.ino === inode)
const audioFile = this.audioFiles.find(af => af.ino === inode)
if (audioFile) return audioFile
if (this.ebookFile && this.ebookFile.ino === inode) return this.ebookFile
return null
@ -367,10 +381,20 @@ class Book {
return payload
}
setEbookFile(libraryFile) {
var ebookFile = new EBookFile()
ebookFile.setData(libraryFile)
this.ebookFile = ebookFile
/**
* Set the EBookFile from a LibraryFile
* If null then ebookFile will be removed from the book
*
* @param {LibraryFile} [libraryFile]
*/
setEbookFile(libraryFile = null) {
if (!libraryFile) {
this.ebookFile = null
} else {
const ebookFile = new EBookFile()
ebookFile.setData(libraryFile)
this.ebookFile = ebookFile
}
}
addAudioFile(audioFile) {

View file

@ -2,7 +2,7 @@ const Logger = require('../../Logger')
const PodcastEpisode = require('../entities/PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator')
const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils')
const { createNewSortInstance } = require('../../libs/fastSort')
const naturalSort = createNewSortInstance({
@ -11,6 +11,7 @@ const naturalSort = createNewSortInstance({
class Podcast {
constructor(podcast) {
this.id = null
this.libraryItemId = null
this.metadata = null
this.coverPath = null
@ -32,6 +33,7 @@ class Podcast {
}
construct(podcast) {
this.id = podcast.id
this.libraryItemId = podcast.libraryItemId
this.metadata = new PodcastMetadata(podcast.metadata)
this.coverPath = podcast.coverPath
@ -50,6 +52,7 @@ class Podcast {
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
@ -65,6 +68,7 @@ class Podcast {
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSONMinified(),
coverPath: this.coverPath,
tags: [...this.tags],
@ -80,6 +84,7 @@ class Podcast {
toJSONExpanded() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
@ -280,30 +285,17 @@ class Podcast {
addPodcastEpisode(podcastEpisode) {
this.episodes.push(podcastEpisode)
this.reorderEpisodes()
}
addNewEpisodeFromAudioFile(audioFile, index) {
var pe = new PodcastEpisode()
const pe = new PodcastEpisode()
pe.libraryItemId = this.libraryItemId
pe.podcastId = this.id
audioFile.index = 1 // Only 1 audio file per episode
pe.setDataFromAudioFile(audioFile, index)
this.episodes.push(pe)
}
reorderEpisodes() {
var hasUpdates = false
this.episodes = naturalSort(this.episodes).desc((ep) => ep.publishedAt)
for (let i = 0; i < this.episodes.length; i++) {
if (this.episodes[i].index !== (i + 1)) {
this.episodes[i].index = i + 1
hasUpdates = true
}
}
return hasUpdates
}
removeEpisode(episodeId) {
const episode = this.episodes.find(ep => ep.id === episodeId)
if (episode) {
@ -329,6 +321,11 @@ class Podcast {
}
getEpisode(episodeId) {
if (!episodeId) return null
// Support old episode ids for mobile downloads
if (episodeId.startsWith('ep_')) return this.episodes.find(ep => ep.oldEpisodeId == episodeId)
return this.episodes.find(ep => ep.id == episodeId)
}

View file

@ -218,7 +218,7 @@ class BookMetadata {
// Updates author name
updateAuthor(updatedAuthor) {
var author = this.authors.find(au => au.id === updatedAuthor.id)
const author = this.authors.find(au => au.id === updatedAuthor.id)
if (!author || author.name == updatedAuthor.name) return false
author.name = updatedAuthor.name
return true

View file

@ -0,0 +1,104 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils')
// REF: https://nodemailer.com/smtp/
class EmailSettings {
constructor(settings = null) {
this.id = 'email-settings'
this.host = null
this.port = 465
this.secure = true
this.user = null
this.pass = null
this.testAddress = null
this.fromAddress = null
// Array of { name:String, email:String }
this.ereaderDevices = []
if (settings) {
this.construct(settings)
}
}
construct(settings) {
this.host = settings.host
this.port = settings.port
this.secure = !!settings.secure
this.user = settings.user
this.pass = settings.pass
this.testAddress = settings.testAddress
this.fromAddress = settings.fromAddress
this.ereaderDevices = settings.ereaderDevices?.map(d => ({ ...d })) || []
}
toJSON() {
return {
id: this.id,
host: this.host,
port: this.port,
secure: this.secure,
user: this.user,
pass: this.pass,
testAddress: this.testAddress,
fromAddress: this.fromAddress,
ereaderDevices: this.ereaderDevices.map(d => ({ ...d }))
}
}
update(payload) {
if (!payload) return false
if (payload.port !== undefined) {
if (isNullOrNaN(payload.port)) payload.port = 465
else payload.port = Number(payload.port)
}
if (payload.secure !== undefined) payload.secure = !!payload.secure
if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined
let hasUpdates = false
const json = this.toJSON()
for (const key in json) {
if (key === 'id') continue
if (payload[key] !== undefined && !areEquivalent(payload[key], json[key])) {
this[key] = copyValue(payload[key])
hasUpdates = true
}
}
return hasUpdates
}
getTransportObject() {
const payload = {
host: this.host,
secure: this.secure
}
if (this.port) payload.port = this.port
if (this.user && this.pass !== undefined) {
payload.auth = {
user: this.user,
pass: this.pass
}
}
return payload
}
getEReaderDevices(user) {
// Only accessible to admin or up
if (!user.isAdminOrUp) {
return []
}
return this.ereaderDevices.map(d => ({ ...d }))
}
getEReaderDevice(deviceName) {
return this.ereaderDevices.find(d => d.name === deviceName)
}
}
module.exports = EmailSettings

Some files were not shown because too many files have changed in this diff Show more