diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue
index a8e8ae469..84cd87a0f 100644
--- a/client/components/app/BookShelfCategorized.vue
+++ b/client/components/app/BookShelfCategorized.vue
@@ -52,7 +52,7 @@ export default {
},
computed: {
supportedShelves() {
- return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators'].includes(shelf.type))
+ return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators', 'audible-preorder'].includes(shelf.type))
},
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue
index fac89a70b..499ac2485 100644
--- a/client/components/app/BookShelfRow.vue
+++ b/client/components/app/BookShelfRow.vue
@@ -32,12 +32,17 @@
+
-
{{ $strings[shelf.labelStringKey] }}
+ {{ $strings[shelf.labelStringKey] || shelf.label }}
diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue
index 32e7e694a..e2ad4bc8a 100644
--- a/client/components/app/ConfigSideNav.vue
+++ b/client/components/app/ConfigSideNav.vue
@@ -114,6 +114,11 @@ export default {
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
+ },
+ {
+ id: 'config-audible',
+ title: 'Audible',
+ path: '/config/audible'
}
]
diff --git a/client/components/cards/AudiblePreorderCard.vue b/client/components/cards/AudiblePreorderCard.vue
new file mode 100644
index 000000000..a23918186
--- /dev/null
+++ b/client/components/cards/AudiblePreorderCard.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
![]()
+
+ headphones
+
+
+
+
+
{{ formatReleaseDate(book.releaseDate) }}
+
+
+
+
+
+
{{ book.title }}
+
{{ authorLine }}
+
{{ seriesLine }}
+
+
+
+
+
+
diff --git a/client/components/widgets/ItemSlider.vue b/client/components/widgets/ItemSlider.vue
index 5b96e51b4..9de9c271a 100644
--- a/client/components/widgets/ItemSlider.vue
+++ b/client/components/widgets/ItemSlider.vue
@@ -82,6 +82,11 @@ export default {
component: 'cards-lazy-book-card',
itemPropName: 'book-mount',
itemIdFunc: (item) => item.id
+ },
+ 'audible-preorder': {
+ component: 'cards-audible-preorder-card',
+ itemPropName: 'book',
+ itemIdFunc: (item) => item.id
}
}
}
diff --git a/client/pages/config.vue b/client/pages/config.vue
index c4fe24468..28f548b58 100644
--- a/client/pages/config.vue
+++ b/client/pages/config.vue
@@ -58,6 +58,7 @@ export default {
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
+ else if (pageName === 'audible') return 'Audible'
}
return this.$strings.HeaderSettings
}
diff --git a/client/pages/config/audible.vue b/client/pages/config/audible.vue
new file mode 100644
index 000000000..4e9b1a4d5
--- /dev/null
+++ b/client/pages/config/audible.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+ Connect your Audible account to track preorders. You'll need an access token — see instructions below.
+
+
+
+ How to get your tokens
+
+
Use audible-cli (Python) to authenticate once:
+
pip install audible-cli
+audible quickstart
+
After setup, run the following to extract your tokens:
+
cat ~/.audible/*.json | jq '{access_token, refresh_token, device_serial: .device_info.device_serial_number}'
+
Paste the access_token, refresh_token, and device_serial values into the fields below.
+
+
+
+
+
+
+
+
+
+
+
+
{{ account.email }}
+
Region: {{ account.region }} • Last sync: {{ account.lastSync ? formatDate(account.lastSync) : 'Never' }}
+
+
+ Sync
+ Remove
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connect an Audible account above to see preorders.
+ No preorders found. Click "Sync" to refresh.
+
+
{{ preorders.length }} preorder{{ preorders.length !== 1 ? 's' : '' }} found
+
+
+
![]()
+
+ headphones
+
+
+
{{ book.title }}
+
{{ book.subtitle }}
+
{{ book.authors.join(', ') }}
+
{{ book.seriesName }} #{{ book.seriesPosition }}
+
Releases: {{ formatReleaseDate(book.releaseDate) }}
+
{{ book.publisherName }}
+
+
+
+
+
+
+
+
+
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index fb2bcb281..81f4ab660 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -313,6 +313,7 @@
"LabelDevice": "Device",
"LabelDeviceInfo": "Device Info",
"LabelDeviceIsAvailableTo": "Device is available to...",
+ "LabelAudiblePreorders": "Audible Preorders",
"LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
diff --git a/server/Database.js b/server/Database.js
index 213c2c61b..4f176ede1 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -157,6 +157,16 @@ class Database {
return this.models.mediaItemShare
}
+ /** @type {typeof import('./models/AudibleAccount')} */
+ get audibleAccountModel() {
+ return this.models.audibleAccount
+ }
+
+ /** @type {typeof import('./models/AudibleBook')} */
+ get audibleBookModel() {
+ return this.models.audibleBook
+ }
+
/** @type {typeof import('./models/Device')} */
get deviceModel() {
return this.models.device
@@ -345,6 +355,8 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
+ require('./models/AudibleAccount').init(this.sequelize)
+ require('./models/AudibleBook').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
diff --git a/server/controllers/AudibleController.js b/server/controllers/AudibleController.js
new file mode 100644
index 000000000..77e78ca73
--- /dev/null
+++ b/server/controllers/AudibleController.js
@@ -0,0 +1,261 @@
+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()
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index 73b3d5c60..b8875d8f1 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -894,6 +894,41 @@ class LibraryController {
.map((v) => v.trim().toLowerCase())
.filter((v) => !!v)
const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
+
+ // Inject Audible preorders shelf if this user has an account configured for this library
+ try {
+ const audibleAccounts = await Database.audibleAccountModel.findAll({
+ where: { userId: req.user.id, isActive: true, libraryId: req.library.id }
+ })
+ if (audibleAccounts.length) {
+ const preorderBooks = await Database.audibleBookModel.findAll({
+ where: {
+ audibleAccountId: audibleAccounts.map((a) => a.id),
+ status: 'preorder'
+ }
+ })
+ if (preorderBooks.length) {
+ const sorted = preorderBooks.slice().sort((a, b) => {
+ const toTime = (d) => { if (!d) return Infinity; const dt = new Date(d.length === 10 ? d + 'T00:00:00' : d); return isNaN(dt.getTime()) ? Infinity : dt.getTime() }
+ return toTime(a.releaseDate) - toTime(b.releaseDate)
+ })
+ const preorderShelf = {
+ id: 'audible-preorders',
+ label: 'Audible Preorders',
+ labelStringKey: 'LabelAudiblePreorders',
+ type: 'audible-preorder',
+ entities: sorted.map((b) => b.toClientJson())
+ }
+ // When multiple accounts map to this library, use the minimum (earliest) shelf position
+ const minPosition = Math.min(...audibleAccounts.map((a) => a.shelfPosition || 0))
+ const position = Math.min(minPosition, shelves.length)
+ shelves.splice(position, 0, preorderShelf)
+ }
+ }
+ } catch (err) {
+ Logger.error('[LibraryController] Failed to inject Audible preorders shelf', err)
+ }
+
res.json(shelves)
}
diff --git a/server/migrations/v2.33.2-setup-audible.js b/server/migrations/v2.33.2-setup-audible.js
new file mode 100644
index 000000000..c5fc6d064
--- /dev/null
+++ b/server/migrations/v2.33.2-setup-audible.js
@@ -0,0 +1,131 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface
+ * @property {import('../Logger')} logger
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context
+ */
+
+const migrationVersion = '2.33.2'
+const migrationName = `${migrationVersion}-setup-audible`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * Creates audibleAccounts and audibleBooks tables if they don't exist,
+ * adds missing columns to existing tables, and ensures all indexes exist.
+ *
+ * @param {MigrationOptions} options
+ * @returns {Promise
}
+ */
+async function up({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
+
+ // Safe index creation — silently ignores "already exists" errors
+ const addIndexSafe = async (table, fields, options = {}) => {
+ try {
+ await queryInterface.addIndex(table, fields, options)
+ } catch (err) {
+ logger.info(`${loggerPrefix} index on ${table}(${fields.join(',')}) already exists, skipping`)
+ }
+ }
+
+ // audibleAccounts table
+ const accountsDesc = await queryInterface.describeTable('audibleAccounts').catch(() => null)
+ if (!accountsDesc) {
+ logger.info(`${loggerPrefix} creating table "audibleAccounts"`)
+ await queryInterface.createTable('audibleAccounts', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ email: { type: DataTypes.STRING, allowNull: false },
+ region: { type: DataTypes.STRING, allowNull: false, defaultValue: 'us' },
+ encryptedToken: { type: DataTypes.TEXT, allowNull: false },
+ encryptedCookies: { type: DataTypes.TEXT, allowNull: true },
+ libraryId: { type: DataTypes.UUID, allowNull: true },
+ shelfPosition: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
+ lastSync: { type: DataTypes.DATE },
+ isActive: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
+ createdAt: { type: DataTypes.DATE, allowNull: false },
+ updatedAt: { type: DataTypes.DATE, allowNull: false },
+ userId: { type: DataTypes.UUID, references: { model: 'users', key: 'id' }, onDelete: 'CASCADE' }
+ })
+ logger.info(`${loggerPrefix} created table "audibleAccounts"`)
+ } else {
+ if (!accountsDesc.encryptedCookies) {
+ logger.info(`${loggerPrefix} adding column "encryptedCookies" to "audibleAccounts"`)
+ await queryInterface.addColumn('audibleAccounts', 'encryptedCookies', { type: DataTypes.TEXT, allowNull: true })
+ }
+ if (!accountsDesc.libraryId) {
+ logger.info(`${loggerPrefix} adding column "libraryId" to "audibleAccounts"`)
+ await queryInterface.addColumn('audibleAccounts', 'libraryId', { type: DataTypes.TEXT, allowNull: true })
+ }
+ if (!accountsDesc.shelfPosition) {
+ logger.info(`${loggerPrefix} adding column "shelfPosition" to "audibleAccounts"`)
+ await queryInterface.addColumn('audibleAccounts', 'shelfPosition', { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 })
+ }
+ }
+
+ // Indexes on audibleAccounts (applied to both new and existing tables)
+ await addIndexSafe('audibleAccounts', ['userId'])
+ await addIndexSafe('audibleAccounts', ['email', 'userId'], { unique: true, name: 'audibleAccounts_email_userId_unique' })
+
+ // audibleBooks table
+ const booksDesc = await queryInterface.describeTable('audibleBooks').catch(() => null)
+ if (!booksDesc) {
+ logger.info(`${loggerPrefix} creating table "audibleBooks"`)
+ await queryInterface.createTable('audibleBooks', {
+ id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
+ asin: { type: DataTypes.STRING, allowNull: false },
+ title: { type: DataTypes.STRING, allowNull: false },
+ subtitle: { type: DataTypes.STRING },
+ authors: { type: DataTypes.JSON, defaultValue: [] },
+ narrators: { type: DataTypes.JSON, defaultValue: [] },
+ seriesName: { type: DataTypes.STRING },
+ seriesPosition: { type: DataTypes.STRING },
+ releaseDate: { type: DataTypes.STRING },
+ coverUrl: { type: DataTypes.TEXT },
+ publisherName: { type: DataTypes.STRING },
+ summary: { type: DataTypes.TEXT },
+ runtimeLengthMin: { type: DataTypes.INTEGER },
+ status: { type: DataTypes.STRING, allowNull: false, defaultValue: 'available' },
+ lastChecked: { type: DataTypes.DATE },
+ createdAt: { type: DataTypes.DATE, allowNull: false },
+ updatedAt: { type: DataTypes.DATE, allowNull: false },
+ audibleAccountId: { type: DataTypes.UUID, references: { model: 'audibleAccounts', key: 'id' }, onDelete: 'CASCADE' }
+ })
+ logger.info(`${loggerPrefix} created table "audibleBooks"`)
+ } else {
+ logger.info(`${loggerPrefix} table "audibleBooks" already exists`)
+ }
+
+ // Indexes on audibleBooks (applied to both new and existing tables)
+ await addIndexSafe('audibleBooks', ['audibleAccountId'])
+ await addIndexSafe('audibleBooks', ['asin', 'audibleAccountId'], { unique: true, name: 'audibleBooks_asin_accountId_unique' })
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * @param {MigrationOptions} options
+ * @returns {Promise}
+ */
+async function down({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ const booksDesc = await queryInterface.describeTable('audibleBooks').catch(() => null)
+ if (booksDesc) {
+ await queryInterface.dropTable('audibleBooks')
+ logger.info(`${loggerPrefix} dropped table "audibleBooks"`)
+ }
+
+ const accountsDesc = await queryInterface.describeTable('audibleAccounts').catch(() => null)
+ if (accountsDesc) {
+ await queryInterface.dropTable('audibleAccounts')
+ logger.info(`${loggerPrefix} dropped table "audibleAccounts"`)
+ }
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+module.exports = { up, down }
diff --git a/server/models/AudibleAccount.js b/server/models/AudibleAccount.js
new file mode 100644
index 000000000..b1ea9aa7e
--- /dev/null
+++ b/server/models/AudibleAccount.js
@@ -0,0 +1,170 @@
+const { DataTypes, Model } = require('sequelize')
+const crypto = require('crypto')
+const Logger = require('../Logger')
+
+const ALGORITHM = 'aes-256-cbc'
+
+function getEncryptionKey() {
+ const secret = process.env.AUDIBLE_ENCRYPTION_KEY || process.env.TOKEN_SECRET
+ if (!secret) throw new Error('[AudibleAccount] No encryption key available — set AUDIBLE_ENCRYPTION_KEY or TOKEN_SECRET')
+ return crypto.scryptSync(secret, 'audible-salt', 32)
+}
+
+function encrypt(text) {
+ if (!text) return null
+ const iv = crypto.randomBytes(16)
+ const cipher = crypto.createCipheriv(ALGORITHM, getEncryptionKey(), iv)
+ const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
+ return iv.toString('hex') + ':' + encrypted.toString('hex')
+}
+
+function decrypt(data) {
+ if (!data) return null
+ try {
+ const [ivHex, encHex] = data.split(':')
+ const decipher = crypto.createDecipheriv(ALGORITHM, getEncryptionKey(), Buffer.from(ivHex, 'hex'))
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()])
+ return decrypted.toString('utf8')
+ } catch (err) {
+ Logger.error('[AudibleAccount] Failed to decrypt token', err.message)
+ return null
+ }
+}
+
+/**
+ * @typedef ClientAudibleAccount
+ * @property {string} id
+ * @property {string} userId
+ * @property {string} email
+ * @property {string} region
+ * @property {Date|null} lastSync
+ * @property {boolean} isActive
+ * @property {Date} createdAt
+ */
+
+class AudibleAccount extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {string} */
+ this.id
+ /** @type {string} */
+ this.userId
+ /** @type {string} */
+ this.email
+ /** @type {string} */
+ this.region
+ /** @type {string} encrypted access token */
+ this.encryptedToken
+ /** @type {string|null} encrypted website cookies JSON */
+ this.encryptedCookies
+ /** @type {string|null} library to show the preorders shelf on */
+ this.libraryId
+ /** @type {number} position in the shelf list (0 = first) */
+ this.shelfPosition
+ /** @type {Date|null} */
+ this.lastSync
+ /** @type {boolean} */
+ this.isActive
+ /** @type {Date} */
+ this.createdAt
+ /** @type {Date} */
+ this.updatedAt
+ }
+
+ getAccessToken() {
+ return decrypt(this.encryptedToken)
+ }
+
+ setAccessToken(token) {
+ this.encryptedToken = encrypt(token)
+ }
+
+ getWebsiteCookies() {
+ const raw = decrypt(this.encryptedCookies)
+ if (!raw) return null
+ try { return JSON.parse(raw) } catch { return null }
+ }
+
+ setWebsiteCookies(cookiesObj) {
+ this.encryptedCookies = cookiesObj ? encrypt(JSON.stringify(cookiesObj)) : null
+ }
+
+ /**
+ * @returns {ClientAudibleAccount}
+ */
+ toClientJson() {
+ return {
+ id: this.id,
+ userId: this.userId,
+ email: this.email,
+ region: this.region,
+ libraryId: this.libraryId || null,
+ shelfPosition: this.shelfPosition || 0,
+ lastSync: this.lastSync,
+ isActive: this.isActive,
+ createdAt: this.createdAt
+ }
+ }
+
+ /**
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init(
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ email: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ region: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ defaultValue: 'us'
+ },
+ encryptedToken: {
+ type: DataTypes.TEXT,
+ allowNull: false
+ },
+ encryptedCookies: {
+ type: DataTypes.TEXT,
+ allowNull: true
+ },
+ libraryId: {
+ type: DataTypes.UUID,
+ allowNull: true
+ },
+ shelfPosition: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0
+ },
+ lastSync: DataTypes.DATE,
+ isActive: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: true
+ }
+ },
+ {
+ sequelize,
+ modelName: 'audibleAccount',
+ indexes: [
+ { fields: ['userId'] },
+ { unique: true, fields: ['email', 'userId'] }
+ ]
+ }
+ )
+
+ const { user } = sequelize.models
+ user.hasMany(AudibleAccount, { onDelete: 'CASCADE' })
+ AudibleAccount.belongsTo(user)
+ }
+}
+
+module.exports = AudibleAccount
diff --git a/server/models/AudibleBook.js b/server/models/AudibleBook.js
new file mode 100644
index 000000000..82e822ce4
--- /dev/null
+++ b/server/models/AudibleBook.js
@@ -0,0 +1,147 @@
+const { DataTypes, Model } = require('sequelize')
+
+/**
+ * @typedef ClientAudibleBook
+ * @property {string} id
+ * @property {string} audibleAccountId
+ * @property {string} asin
+ * @property {string} title
+ * @property {string|null} subtitle
+ * @property {string[]} authors
+ * @property {string[]} narrators
+ * @property {string|null} seriesName
+ * @property {string|null} seriesPosition
+ * @property {string|null} releaseDate
+ * @property {string|null} coverUrl
+ * @property {string|null} publisherName
+ * @property {string|null} summary
+ * @property {number|null} runtimeLengthMin
+ * @property {string} status
+ * @property {Date} lastChecked
+ */
+
+class AudibleBook extends Model {
+ constructor(values, options) {
+ super(values, options)
+
+ /** @type {string} */
+ this.id
+ /** @type {string} */
+ this.audibleAccountId
+ /** @type {string} */
+ this.asin
+ /** @type {string} */
+ this.title
+ /** @type {string|null} */
+ this.subtitle
+ /** @type {string[]} */
+ this.authors
+ /** @type {string[]} */
+ this.narrators
+ /** @type {string|null} */
+ this.seriesName
+ /** @type {string|null} */
+ this.seriesPosition
+ /** @type {string|null} */
+ this.releaseDate
+ /** @type {string|null} */
+ this.coverUrl
+ /** @type {string|null} */
+ this.publisherName
+ /** @type {string|null} */
+ this.summary
+ /** @type {number|null} */
+ this.runtimeLengthMin
+ /** @type {'available'|'preorder'|'not_available'} */
+ this.status
+ /** @type {Date} */
+ this.lastChecked
+ /** @type {Date} */
+ this.createdAt
+ /** @type {Date} */
+ this.updatedAt
+ }
+
+ /**
+ * @returns {ClientAudibleBook}
+ */
+ toClientJson() {
+ return {
+ id: this.id,
+ audibleAccountId: this.audibleAccountId,
+ asin: this.asin,
+ title: this.title,
+ subtitle: this.subtitle,
+ authors: this.authors || [],
+ narrators: this.narrators || [],
+ seriesName: this.seriesName,
+ seriesPosition: this.seriesPosition,
+ releaseDate: this.releaseDate,
+ coverUrl: this.coverUrl,
+ publisherName: this.publisherName,
+ summary: this.summary,
+ runtimeLengthMin: this.runtimeLengthMin,
+ status: this.status,
+ lastChecked: this.lastChecked
+ }
+ }
+
+ /**
+ * @param {import('../Database').sequelize} sequelize
+ */
+ static init(sequelize) {
+ super.init(
+ {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ asin: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ title: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ subtitle: DataTypes.STRING,
+ authors: {
+ type: DataTypes.JSON,
+ defaultValue: []
+ },
+ narrators: {
+ type: DataTypes.JSON,
+ defaultValue: []
+ },
+ seriesName: DataTypes.STRING,
+ seriesPosition: DataTypes.STRING,
+ releaseDate: DataTypes.STRING,
+ coverUrl: DataTypes.TEXT,
+ publisherName: DataTypes.STRING,
+ summary: DataTypes.TEXT,
+ runtimeLengthMin: DataTypes.INTEGER,
+ status: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ defaultValue: 'available'
+ },
+ lastChecked: DataTypes.DATE
+ },
+ {
+ sequelize,
+ modelName: 'audibleBook',
+ indexes: [
+ { fields: ['audibleAccountId'] },
+ { unique: true, fields: ['asin', 'audibleAccountId'] }
+ ]
+ }
+ )
+
+ const { audibleAccount } = sequelize.models
+ audibleAccount.hasMany(AudibleBook, { onDelete: 'CASCADE' })
+ AudibleBook.belongsTo(audibleAccount)
+ }
+}
+
+module.exports = AudibleBook
diff --git a/server/providers/AudibleLibrary.js b/server/providers/AudibleLibrary.js
new file mode 100644
index 000000000..b1659e3a6
--- /dev/null
+++ b/server/providers/AudibleLibrary.js
@@ -0,0 +1,229 @@
+const axios = require('axios').default
+const Logger = require('../Logger')
+
+const REGION_DOMAINS = {
+ us: 'com',
+ ca: 'ca',
+ uk: 'co.uk',
+ au: 'com.au',
+ fr: 'fr',
+ de: 'de',
+ jp: 'co.jp',
+ it: 'it',
+ in: 'in',
+ es: 'es'
+}
+
+const ALLOWED_REGIONS = new Set(Object.keys(REGION_DOMAINS))
+
+class AudibleLibrary {
+ #timeout = 15000
+
+ /**
+ * @param {string} region
+ * @returns {string}
+ */
+ getApiBaseUrl(region) {
+ const domain = REGION_DOMAINS[region] || 'com'
+ return `https://api.audible.${domain}`
+ }
+
+ /**
+ * Build auth headers.
+ *
+ * @param {string} accessToken
+ * @returns {Object}
+ */
+ authHeaders(accessToken) {
+ return {
+ Authorization: `Bearer ${accessToken}`,
+ 'x-amz-access-token': accessToken,
+ 'User-Agent': 'Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0'
+ }
+ }
+
+ /**
+ * Refresh an expired access token using the stored refresh token.
+ *
+ * @param {string} refreshToken
+ * @returns {Promise<{accessToken: string, expiresIn: number}|null>}
+ */
+ async refreshAccessToken(refreshToken) {
+ try {
+ const params = new URLSearchParams({
+ app_name: 'Audible',
+ app_version: '3.56.2',
+ source_token: refreshToken,
+ requested_token_type: 'access_token',
+ source_token_type: 'refresh_token'
+ })
+
+ const response = await axios.post('https://api.amazon.com/auth/token', params.toString(), {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ timeout: this.#timeout
+ })
+
+ const accessToken = response.data?.access_token
+ if (!accessToken) {
+ Logger.error('[AudibleLibrary] Refresh response missing access_token')
+ return null
+ }
+
+ return {
+ accessToken,
+ expiresIn: response.data.expires_in || 3600
+ }
+ } catch (err) {
+ Logger.error('[AudibleLibrary] Failed to refresh access token', err.message, JSON.stringify(err.response?.data || ''))
+ return null
+ }
+ }
+
+ /**
+ * Fetch preorders via the Audible orders API (/1.0/orders).
+ *
+ * Steps:
+ * 1. Fetch all orders (the endpoint returns everything in one response).
+ * 2. Collect ASINs where is_preorder=true.
+ * 3. Fetch catalog details for each ASIN individually.
+ * 4. Filter to products with a future release_date.
+ *
+ * @param {string} accessToken
+ * @param {string} region
+ * @returns {Promise