mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
261 lines
8.4 KiB
JavaScript
261 lines
8.4 KiB
JavaScript
const { Request, Response, NextFunction } = require('express')
|
|
const Logger = require('../Logger')
|
|
const Database = require('../Database')
|
|
const AudibleLibrary = require('../providers/AudibleLibrary')
|
|
|
|
const ALLOWED_REGIONS = ['us', 'ca', 'uk', 'au', 'fr', 'de', 'jp', 'it', 'in', 'es']
|
|
|
|
/**
|
|
* @typedef RequestUserObject
|
|
* @property {import('../models/User')} user
|
|
*
|
|
* @typedef {Request & RequestUserObject} RequestWithUser
|
|
*/
|
|
|
|
class AudibleController {
|
|
constructor() {}
|
|
|
|
/**
|
|
* GET /api/audible/accounts
|
|
* Returns all Audible accounts for the requesting user.
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async getAccounts(req, res) {
|
|
const accounts = await Database.audibleAccountModel.findAll({
|
|
where: { userId: req.user.id }
|
|
})
|
|
res.json({ accounts: accounts.map((a) => a.toClientJson()) })
|
|
}
|
|
|
|
/**
|
|
* POST /api/audible/accounts
|
|
* Connect a new Audible account.
|
|
*
|
|
* Body: { email, region, accessToken, refreshToken?, deviceSerial? }
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async createAccount(req, res) {
|
|
const { email, region, accessToken, refreshToken, deviceSerial } = req.body
|
|
|
|
if (!email || !accessToken) {
|
|
return res.status(400).send('email and accessToken are required')
|
|
}
|
|
|
|
// Validate email format
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
return res.status(400).send('Invalid email format')
|
|
}
|
|
|
|
// Whitelist region
|
|
const normalizedRegion = region || 'us'
|
|
if (!ALLOWED_REGIONS.includes(normalizedRegion)) {
|
|
return res.status(400).send(`Invalid region. Allowed: ${ALLOWED_REGIONS.join(', ')}`)
|
|
}
|
|
|
|
const account = Database.audibleAccountModel.build({
|
|
userId: req.user.id,
|
|
email,
|
|
region: normalizedRegion,
|
|
encryptedToken: '',
|
|
isActive: true
|
|
})
|
|
|
|
account.setAccessToken(JSON.stringify({ accessToken, refreshToken: refreshToken || null, deviceSerial: deviceSerial || null }))
|
|
|
|
try {
|
|
await account.save()
|
|
} catch (err) {
|
|
Logger.error('[AudibleController] Failed to save new account', err.message)
|
|
return res.status(500).send('Failed to save account')
|
|
}
|
|
|
|
Logger.info(`[AudibleController] Connected Audible account ${email} for user ${req.user.id}`)
|
|
res.json({ account: account.toClientJson() })
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/audible/accounts/:id
|
|
* Update libraryId and shelfPosition settings for an account.
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async updateAccount(req, res) {
|
|
const account = req.audibleAccount
|
|
const { libraryId, shelfPosition } = req.body
|
|
|
|
if (libraryId !== undefined) account.libraryId = libraryId || null
|
|
if (shelfPosition !== undefined) account.shelfPosition = Number(shelfPosition) || 0
|
|
|
|
try {
|
|
await account.save()
|
|
} catch (err) {
|
|
Logger.error('[AudibleController] Failed to update account settings', err.message)
|
|
return res.status(500).send('Failed to save settings')
|
|
}
|
|
|
|
res.json({ account: account.toClientJson() })
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/audible/accounts/:id
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async deleteAccount(req, res) {
|
|
const account = req.audibleAccount
|
|
|
|
await Database.audibleBookModel.destroy({
|
|
where: { audibleAccountId: account.id }
|
|
})
|
|
await account.destroy()
|
|
|
|
Logger.info(`[AudibleController] Removed Audible account ${account.id}`)
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
/**
|
|
* POST /api/audible/accounts/:id/sync
|
|
* Fetch preorders from Audible and replace stored books atomically.
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async syncAccount(req, res) {
|
|
const account = req.audibleAccount
|
|
|
|
let tokenData
|
|
try {
|
|
tokenData = JSON.parse(account.getAccessToken() || '{}')
|
|
} catch {
|
|
return res.status(400).send('Invalid stored token — reconnect the account')
|
|
}
|
|
|
|
let { accessToken, refreshToken, deviceSerial } = tokenData
|
|
|
|
if (!accessToken) {
|
|
return res.status(400).send('No access token stored — reconnect the account')
|
|
}
|
|
|
|
// Helper: call fetchPreordersFromOrders with automatic token refresh on 401/403
|
|
const fetchWithRefresh = async () => {
|
|
try {
|
|
return await AudibleLibrary.fetchPreordersFromOrders(accessToken, account.region)
|
|
} catch (err) {
|
|
if (err.message === 'UNAUTHORIZED' && refreshToken) {
|
|
Logger.info(`[AudibleController] Access token expired for account ${account.id}, refreshing`)
|
|
let refreshed
|
|
try {
|
|
refreshed = await AudibleLibrary.refreshAccessToken(refreshToken)
|
|
} catch (refreshErr) {
|
|
Logger.error(`[AudibleController] Refresh request threw for account ${account.id}`, refreshErr.message)
|
|
throw new Error('SESSION_EXPIRED')
|
|
}
|
|
if (!refreshed?.accessToken) throw new Error('SESSION_EXPIRED')
|
|
Logger.info(`[AudibleController] Token refreshed for account ${account.id}`)
|
|
accessToken = refreshed.accessToken
|
|
account.setAccessToken(JSON.stringify({ accessToken, refreshToken, deviceSerial }))
|
|
try {
|
|
await account.save()
|
|
} catch (saveErr) {
|
|
Logger.error('[AudibleController] Failed to persist refreshed token', saveErr.message)
|
|
}
|
|
return await AudibleLibrary.fetchPreordersFromOrders(accessToken, account.region)
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
Logger.info(`[AudibleController] Syncing account ${account.id}`)
|
|
let rawItems
|
|
try {
|
|
rawItems = await fetchWithRefresh()
|
|
} catch (err) {
|
|
if (err.message === 'SESSION_EXPIRED') return res.status(401).send('Session expired — reconnect the account')
|
|
Logger.error('[AudibleController] Orders API sync failed', err.message)
|
|
return res.status(err.message === 'UNAUTHORIZED' ? 401 : 500).send('Sync failed')
|
|
}
|
|
|
|
// Atomically replace all stored preorders in a single transaction.
|
|
// lastSync is updated inside the transaction so it only reflects a complete sync.
|
|
const transaction = await Database.sequelize.transaction()
|
|
let synced = 0
|
|
try {
|
|
await Database.audibleBookModel.destroy({ where: { audibleAccountId: account.id }, transaction })
|
|
|
|
const records = rawItems.map((raw) => ({ ...AudibleLibrary.mapItem(raw), audibleAccountId: account.id }))
|
|
if (records.length) {
|
|
await Database.audibleBookModel.bulkCreate(records, { transaction, ignoreDuplicates: true })
|
|
synced = records.length
|
|
}
|
|
|
|
account.lastSync = new Date()
|
|
await account.save({ transaction })
|
|
await transaction.commit()
|
|
} catch (err) {
|
|
await transaction.rollback()
|
|
Logger.error(`[AudibleController] Sync transaction failed for account ${account.id}`, err.message)
|
|
return res.status(500).send('Sync failed — database error')
|
|
}
|
|
|
|
Logger.info(`[AudibleController] Synced ${synced} preorder(s) for account ${account.id}`)
|
|
res.json({ synced })
|
|
}
|
|
|
|
/**
|
|
* GET /api/audible/preorders
|
|
* Return all preorder books for the requesting user across all their accounts.
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
*/
|
|
async getPreorders(req, res) {
|
|
const accounts = await Database.audibleAccountModel.findAll({
|
|
where: { userId: req.user.id, isActive: true }
|
|
})
|
|
|
|
if (!accounts.length) {
|
|
return res.json({ preorders: [] })
|
|
}
|
|
|
|
const accountIds = accounts.map((a) => a.id)
|
|
const books = await Database.audibleBookModel.findAll({
|
|
where: {
|
|
audibleAccountId: accountIds,
|
|
status: 'preorder'
|
|
}
|
|
})
|
|
|
|
res.json({ preorders: books.map((b) => b.toClientJson()) })
|
|
}
|
|
|
|
/**
|
|
* Middleware: load account by :id and verify the requesting user owns it.
|
|
* Always returns 403 (not 404) when an account isn't found or isn't owned,
|
|
* to prevent account ID enumeration.
|
|
*
|
|
* @param {RequestWithUser} req
|
|
* @param {Response} res
|
|
* @param {NextFunction} next
|
|
*/
|
|
async middleware(req, res, next) {
|
|
if (!req.user) return res.sendStatus(401)
|
|
|
|
if (req.params.id) {
|
|
const account = await Database.audibleAccountModel.findByPk(req.params.id)
|
|
// Return 403 for both "not found" and "not owned" — never leak existence via 404
|
|
if (!account || account.userId !== req.user.id) {
|
|
return res.sendStatus(403)
|
|
}
|
|
req.audibleAccount = account
|
|
}
|
|
next()
|
|
}
|
|
}
|
|
|
|
module.exports = new AudibleController()
|