From 792210d8cd9d0eb05dfe8d6f9b3a66c23e556600 Mon Sep 17 00:00:00 2001 From: Michael Vinci Date: Sat, 25 Apr 2026 21:29:48 -0500 Subject: [PATCH] Enable audible scrape of personal user account to list preorders --- .../components/app/BookShelfCategorized.vue | 2 +- client/components/app/BookShelfRow.vue | 7 +- client/components/app/ConfigSideNav.vue | 5 + .../components/cards/AudiblePreorderCard.vue | 67 +++++ client/components/widgets/ItemSlider.vue | 5 + client/pages/config.vue | 1 + client/pages/config/audible.vue | 225 +++++++++++++++ client/strings/en-us.json | 1 + server/Database.js | 12 + server/controllers/AudibleController.js | 261 ++++++++++++++++++ server/controllers/LibraryController.js | 35 +++ server/migrations/v2.33.2-setup-audible.js | 131 +++++++++ server/models/AudibleAccount.js | 170 ++++++++++++ server/models/AudibleBook.js | 147 ++++++++++ server/providers/AudibleLibrary.js | 229 +++++++++++++++ server/routers/ApiRouter.js | 20 ++ 16 files changed, 1316 insertions(+), 2 deletions(-) create mode 100644 client/components/cards/AudiblePreorderCard.vue create mode 100644 client/pages/config/audible.vue create mode 100644 server/controllers/AudibleController.js create mode 100644 server/migrations/v2.33.2-setup-audible.js create mode 100644 server/models/AudibleAccount.js create mode 100644 server/models/AudibleBook.js create mode 100644 server/providers/AudibleLibrary.js 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 @@ + + + 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 @@ + + + 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} catalog product objects ready for mapItem() + */ + async fetchPreordersFromOrders(accessToken, region = 'us') { + const baseUrl = this.getApiBaseUrl(region) + + let ordersResponse + try { + ordersResponse = await axios.get(`${baseUrl}/1.0/orders`, { + params: { num_results: 999, sort_by: '-PurchaseDate' }, + headers: this.authHeaders(accessToken), + timeout: this.#timeout + }) + } catch (err) { + const status = err.response?.status + if (status === 401 || status === 403) throw new Error('UNAUTHORIZED') + Logger.error('[AudibleLibrary] fetchPreordersFromOrders failed', err.message, JSON.stringify(err.response?.data || '')) + throw err + } + + const orders = ordersResponse.data?.orders || [] + Logger.info(`[AudibleLibrary] Orders API returned ${orders.length} order(s)`) + + // Warn if the response may be truncated (some API versions include pagination metadata) + if (ordersResponse.data?.has_more || ordersResponse.data?.next_token) { + Logger.warn('[AudibleLibrary] Orders response indicates more pages exist — some preorders may be missed. Consider implementing pagination.') + } + + const preorderAsins = [] + for (const order of orders) { + for (const item of order.items || []) { + if (item.is_preorder === true && item.asin) { + preorderAsins.push(item.asin) + } + } + } + + Logger.info(`[AudibleLibrary] Found ${preorderAsins.length} historical preorder ASIN(s)`) + + if (!preorderAsins.length) return [] + + const allProducts = [] + const results = await Promise.allSettled( + preorderAsins.map(async (asin) => { + const resp = await axios.get(`${baseUrl}/1.0/catalog/products/${asin}`, { + params: { response_groups: 'product_attrs,product_details,media,series,contributors', image_sizes: '500' }, + headers: this.authHeaders(accessToken), + timeout: this.#timeout + }) + const product = resp.data?.product + // Require asin and title — title is NOT NULL in the DB schema + if (product?.asin && product?.title) return product + return null + }) + ) + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + allProducts.push(result.value) + } else if (result.status === 'rejected') { + const status = result.reason?.response?.status + if (status === 401 || status === 403) throw new Error('UNAUTHORIZED') + Logger.warn(`[AudibleLibrary] Catalog fetch failed for one ASIN`, result.reason?.message) + } + } + + const now = new Date() + const upcoming = allProducts.filter((p) => { + const dateStr = p.release_date || p.publication_datetime || p.product_site_launch_date + if (!dateStr) return false + const d = new Date(dateStr) + if (isNaN(d.getTime())) return false + return d > now + }) + + Logger.info(`[AudibleLibrary] ${upcoming.length} upcoming preorder(s) (of ${allProducts.length} ever preordered)`) + return upcoming + } + + /** + * Map a raw Audible catalog product to our AudibleBook fields. + * Uses the same date field priority as the upcoming-filter above. + * + * @param {Object} item + * @returns {Object} + */ + mapItem(item) { + const authors = (item.authors || []).map((a) => a.name).filter(Boolean) + const narrators = (item.narrators || []).map((n) => n.name).filter(Boolean) + + let seriesName = null + let seriesPosition = null + const series = item.series || [] + if (series.length > 0) { + seriesName = series[0].title || series[0].asin || null + seriesPosition = series[0].sequence || null + } + + // Pick the best available cover image size (prefer 500, then descend) + let coverUrl = null + const images = item.product_images || {} + const preferredSizes = ['500', '1215', '1024', '730', '408', '360', '300', '252', '200'] + for (const size of preferredSizes) { + if (images[size]) { coverUrl = images[size]; break } + } + if (!coverUrl) { + const available = Object.keys(images).sort((a, b) => Number(b) - Number(a)) + if (available.length) coverUrl = images[available[0]] + } + + return { + asin: item.asin, + title: item.title, + subtitle: item.subtitle || null, + authors, + narrators, + seriesName, + seriesPosition, + releaseDate: item.release_date || item.publication_datetime || item.product_site_launch_date || null, + coverUrl, + publisherName: item.publisher_name || null, + summary: item.merchandising_summary || item.publisher_summary || null, + runtimeLengthMin: item.runtime_length_min || null, + status: 'preorder', + lastChecked: new Date() + } + } + + /** + * Returns the set of valid region codes. + * @returns {Set} + */ + get allowedRegions() { + return ALLOWED_REGIONS + } +} + +module.exports = new AudibleLibrary() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e89b364f3..71f8a92a1 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -31,6 +31,8 @@ const CacheController = require('../controllers/CacheController') const ToolsController = require('../controllers/ToolsController') const RSSFeedController = require('../controllers/RSSFeedController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') +const AudibleController = require('../controllers/AudibleController') +const { rateLimit } = require('express-rate-limit') const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') const StatsController = require('../controllers/StatsController') @@ -335,6 +337,24 @@ class ApiRouter { this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this)) this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this)) + // + // Audible Routes + // + const audibleSyncLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: 10, + keyGenerator: (req) => `audible-sync:${req.user?.id || req.ip}`, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => res.status(429).json({ error: 'Too many sync requests — please wait before syncing again' }) + }) + this.router.get('/audible/accounts', AudibleController.middleware.bind(this), AudibleController.getAccounts.bind(this)) + this.router.post('/audible/accounts', AudibleController.middleware.bind(this), AudibleController.createAccount.bind(this)) + this.router.patch('/audible/accounts/:id', AudibleController.middleware.bind(this), AudibleController.updateAccount.bind(this)) + this.router.delete('/audible/accounts/:id', AudibleController.middleware.bind(this), AudibleController.deleteAccount.bind(this)) + this.router.post('/audible/accounts/:id/sync', AudibleController.middleware.bind(this), audibleSyncLimiter, AudibleController.syncAccount.bind(this)) + this.router.get('/audible/preorders', AudibleController.middleware.bind(this), AudibleController.getPreorders.bind(this)) + // // Misc Routes //