Enable audible scrape of personal user account to list preorders

This commit is contained in:
Michael Vinci 2026-04-25 21:29:48 -05:00
parent 3ccdcaec1a
commit 792210d8cd
16 changed files with 1316 additions and 2 deletions

View file

@ -52,7 +52,7 @@ export default {
}, },
computed: { computed: {
supportedShelves() { 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() { userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp'] return this.$store.getters['user/getIsAdminOrUp']

View file

@ -32,12 +32,17 @@
<cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" /> <cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'audible-preorder'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-audible-preorder-card :key="entity.id" :book="entity" class="mx-2e" />
</template>
</div>
</div> </div>
</div> </div>
<div class="relative"> <div class="relative">
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md"> <div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em 0.5em` }"> <div class="w-full h-full shinyBlack flex items-center justify-center rounded-xs border" :style="{ padding: `0em 0.5em` }">
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2> <h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] || shelf.label }}</h2>
</div> </div>
</div> </div>

View file

@ -114,6 +114,11 @@ export default {
id: 'config-authentication', id: 'config-authentication',
title: this.$strings.HeaderAuthentication, title: this.$strings.HeaderAuthentication,
path: '/config/authentication' path: '/config/authentication'
},
{
id: 'config-audible',
title: 'Audible',
path: '/config/audible'
} }
] ]

View file

@ -0,0 +1,67 @@
<template>
<div class="relative" :style="{ width: coverWidth + 'px' }">
<div @mouseover="isHovering = true" @mouseleave="isHovering = false" class="cursor-default">
<!-- Cover image -->
<div class="relative rounded-sm overflow-hidden bg-primary" :style="{ width: coverWidth + 'px', height: coverHeight + 'px' }">
<img v-if="book.coverUrl" :src="book.coverUrl" :alt="book.title" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center bg-primary/50">
<span class="material-symbols text-white/30" style="font-size: 3rem">headphones</span>
</div>
<!-- Release date badge -->
<div v-if="book.releaseDate" class="absolute bottom-0 left-0 right-0 bg-black/70 px-1 py-0.5 text-center">
<p class="text-yellow-400 font-semibold truncate" :style="{ fontSize: 0.6 + 'em' }">{{ formatReleaseDate(book.releaseDate) }}</p>
</div>
</div>
<!-- Title & author below cover -->
<div class="pt-1 px-0.5" :style="{ width: coverWidth + 'px' }">
<p class="font-semibold truncate leading-tight" :style="{ fontSize: 0.75 + 'em' }">{{ book.title }}</p>
<p v-if="authorLine" class="text-gray-300 truncate" :style="{ fontSize: 0.65 + 'em' }">{{ authorLine }}</p>
<p v-if="seriesLine" class="text-gray-400 truncate" :style="{ fontSize: 0.6 + 'em' }">{{ seriesLine }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
}
},
data() {
return { isHovering: false }
},
computed: {
sizeMultiplier() {
return this.$store.getters['user/getSizeMultiplier']
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
coverHeight() {
return 192 * this.sizeMultiplier
},
coverWidth() {
return this.coverHeight / this.bookCoverAspectRatio
},
authorLine() {
return (this.book.authors || []).join(', ') || null
},
seriesLine() {
if (!this.book.seriesName) return null
return this.book.seriesPosition ? `${this.book.seriesName} #${this.book.seriesPosition}` : this.book.seriesName
}
},
methods: {
formatReleaseDate(d) {
if (!d) return ''
const date = new Date(d.length === 10 ? d + 'T00:00:00' : d)
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
}
}
</script>

View file

@ -82,6 +82,11 @@ export default {
component: 'cards-lazy-book-card', component: 'cards-lazy-book-card',
itemPropName: 'book-mount', itemPropName: 'book-mount',
itemIdFunc: (item) => item.id itemIdFunc: (item) => item.id
},
'audible-preorder': {
component: 'cards-audible-preorder-card',
itemPropName: 'book',
itemIdFunc: (item) => item.id
} }
} }
} }

View file

@ -58,6 +58,7 @@ export default {
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
else if (pageName === 'audible') return 'Audible'
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }

View file

@ -0,0 +1,225 @@
<template>
<div>
<!-- Connect account panel -->
<app-settings-content header-text="Audible Accounts">
<div class="mb-4 text-sm text-gray-300">
Connect your Audible account to track preorders. You'll need an access token see instructions below.
</div>
<details class="mb-6 text-sm">
<summary class="cursor-pointer text-blue-400 hover:text-blue-300">How to get your tokens</summary>
<div class="mt-2 pl-4 border-l border-white/10 text-gray-300 space-y-2">
<p>Use <code>audible-cli</code> (Python) to authenticate once:</p>
<pre class="bg-black/30 rounded p-2 text-xs overflow-x-auto">pip install audible-cli
audible quickstart</pre>
<p>After setup, run the following to extract your tokens:</p>
<pre class="bg-black/30 rounded p-2 text-xs overflow-x-auto">cat ~/.audible/*.json | jq '{access_token, refresh_token, device_serial: .device_info.device_serial_number}'</pre>
<p>Paste the <code>access_token</code>, <code>refresh_token</code>, and <code>device_serial</code> values into the fields below.</p>
</div>
</details>
<form @submit.prevent="connectAccount" class="space-y-3 max-w-md">
<ui-text-input v-model="form.email" label="Audible Email" placeholder="you@example.com" type="email" :disabled="connecting" />
<ui-dropdown v-model="form.region" label="Region" :items="regionOptions" small :disabled="connecting" />
<ui-text-input v-model="form.accessToken" label="Access Token" placeholder="Atna|..." :disabled="connecting" />
<ui-text-input v-model="form.refreshToken" label="Refresh Token (optional, for auto-renewal)" placeholder="Atnr|..." :disabled="connecting" />
<ui-text-input v-model="form.deviceSerial" label="Device Serial (optional)" placeholder="from tokens.json" :disabled="connecting" />
<div class="flex justify-end pt-1">
<ui-btn type="submit" :loading="connecting" small>Connect</ui-btn>
</div>
</form>
</app-settings-content>
<!-- Connected accounts -->
<app-settings-content v-if="accounts.length" header-text="Connected Accounts">
<div v-for="account in accounts" :key="account.id" class="py-3 border-b border-white/10 last:border-0">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ account.email }}</p>
<p class="text-xs text-gray-400">Region: {{ account.region }} &bull; Last sync: {{ account.lastSync ? formatDate(account.lastSync) : 'Never' }}</p>
</div>
<div class="flex gap-2">
<ui-btn small :loading="syncingId === account.id" color="bg-primary" @click="syncAccount(account)">Sync</ui-btn>
<ui-btn small color="bg-error" @click="removeAccount(account)">Remove</ui-btn>
</div>
</div>
<!-- Shelf display settings -->
<div class="mt-2 flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-40">
<label class="block text-xs text-gray-400 mb-1">Show preorders row on library</label>
<select :value="account.libraryId || ''" @change="updateAccountSetting(account, 'libraryId', $event.target.value || null)" class="w-full bg-primary/20 border border-white/10 rounded px-2 py-1 text-sm text-white focus:outline-none">
<option value=""> None </option>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</div>
<div class="w-28">
<label class="block text-xs text-gray-400 mb-1">Row position</label>
<input type="number" min="0" max="20" :value="account.shelfPosition || 0" @change="updateAccountSetting(account, 'shelfPosition', parseInt($event.target.value) || 0)" class="w-full bg-primary/20 border border-white/10 rounded px-2 py-1 text-sm text-white focus:outline-none" />
</div>
</div>
</div>
</app-settings-content>
<!-- Preorders -->
<app-settings-content header-text="Preorders">
<div v-if="!accounts.length" class="text-sm text-gray-400 py-2">Connect an Audible account above to see preorders.</div>
<div v-else-if="!preorders.length" class="text-sm text-gray-400 py-2">No preorders found. Click "Sync" to refresh.</div>
<div v-else>
<p class="text-xs text-gray-400 mb-3">{{ preorders.length }} preorder{{ preorders.length !== 1 ? 's' : '' }} found</p>
<div class="grid gap-3">
<div v-for="book in preorders" :key="book.id" class="flex gap-3 p-3 bg-primary/10 rounded-lg border border-white/5">
<img v-if="book.coverUrl" :src="book.coverUrl" :alt="book.title" class="w-16 h-16 object-cover rounded flex-shrink-0" />
<div v-else class="w-16 h-16 bg-primary/30 rounded flex items-center justify-center flex-shrink-0">
<span class="material-symbols text-2xl text-white/30">headphones</span>
</div>
<div class="min-w-0">
<p class="font-medium text-sm truncate">{{ book.title }}</p>
<p v-if="book.subtitle" class="text-xs text-gray-400 truncate">{{ book.subtitle }}</p>
<p class="text-xs text-gray-300">{{ book.authors.join(', ') }}</p>
<p v-if="book.seriesName" class="text-xs text-gray-400">{{ book.seriesName }}<span v-if="book.seriesPosition"> #{{ book.seriesPosition }}</span></p>
<p v-if="book.releaseDate" class="text-xs text-yellow-400 mt-1">Releases: {{ formatReleaseDate(book.releaseDate) }}</p>
<p v-if="book.publisherName" class="text-xs text-gray-500">{{ book.publisherName }}</p>
</div>
</div>
</div>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
accounts: [],
preorders: [],
libraries: [],
connecting: false,
syncingId: null,
form: {
email: '',
region: 'us',
accessToken: '',
refreshToken: '',
deviceSerial: ''
},
regionOptions: [
{ value: 'us', text: 'United States' },
{ value: 'ca', text: 'Canada' },
{ value: 'uk', text: 'United Kingdom' },
{ value: 'au', text: 'Australia' },
{ value: 'de', text: 'Germany' },
{ value: 'fr', text: 'France' },
{ value: 'it', text: 'Italy' },
{ value: 'es', text: 'Spain' },
{ value: 'jp', text: 'Japan' },
{ value: 'in', text: 'India' }
]
}
},
async mounted() {
await Promise.all([this.loadAccounts(), this.loadLibraries()])
if (this.accounts.length) await this.loadPreorders()
},
methods: {
async loadLibraries() {
try {
const data = await this.$axios.$get('/api/libraries')
this.libraries = (data.libraries || []).map((l) => ({ id: l.id, name: l.name }))
} catch (err) {
console.error('Failed to load libraries', err)
}
},
async loadAccounts() {
try {
const data = await this.$axios.$get('/api/audible/accounts')
this.accounts = data.accounts || []
} catch (err) {
console.error('Failed to load Audible accounts', err)
}
},
async loadPreorders() {
try {
const data = await this.$axios.$get('/api/audible/preorders')
this.preorders = data.preorders || []
} catch (err) {
console.error('Failed to load preorders', err)
}
},
async connectAccount() {
if (!this.form.email || !this.form.accessToken) {
this.$toast.error('Email and access token are required')
return
}
this.connecting = true
try {
const data = await this.$axios.$post('/api/audible/accounts', {
email: this.form.email,
region: this.form.region,
accessToken: this.form.accessToken,
refreshToken: this.form.refreshToken || undefined,
deviceSerial: this.form.deviceSerial || undefined
})
this.accounts.push(data.account)
this.form = { email: '', region: 'us', accessToken: '', refreshToken: '', deviceSerial: '' }
this.$toast.success('Account connected')
} catch (err) {
console.error('Failed to connect Audible account', err)
this.$toast.error(err.response?.data || 'Failed to connect account')
} finally {
this.connecting = false
}
},
async syncAccount(account) {
this.syncingId = account.id
try {
const data = await this.$axios.$post(`/api/audible/accounts/${account.id}/sync`)
this.$toast.success(`Synced ${data.synced} preorder(s)`)
await this.loadAccounts()
await this.loadPreorders()
} catch (err) {
console.error('Sync failed', err)
this.$toast.error(err.response?.data || 'Sync failed')
} finally {
this.syncingId = null
}
},
async removeAccount(account) {
if (!confirm(`Remove Audible account ${account.email}?`)) return
try {
await this.$axios.$delete(`/api/audible/accounts/${account.id}`)
this.accounts = this.accounts.filter((a) => a.id !== account.id)
this.preorders = this.preorders.filter((b) => b.audibleAccountId !== account.id)
this.$toast.success('Account removed')
} catch (err) {
console.error('Failed to remove account', err)
this.$toast.error('Failed to remove account')
}
},
async updateAccountSetting(account, field, value) {
try {
const data = await this.$axios.$patch(`/api/audible/accounts/${account.id}`, { [field]: value })
const idx = this.accounts.findIndex((a) => a.id === account.id)
if (idx !== -1) this.$set(this.accounts, idx, data.account)
} catch (err) {
console.error('Failed to update account setting', err)
this.$toast.error('Failed to save setting')
}
},
formatDate(d) {
return new Date(d).toLocaleDateString()
},
formatReleaseDate(d) {
if (!d) return ''
// d may be 'YYYY-MM-DD' or a full ISO string
const date = new Date(d.length === 10 ? d + 'T00:00:00' : d)
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
}
}
}
</script>

View file

@ -313,6 +313,7 @@
"LabelDevice": "Device", "LabelDevice": "Device",
"LabelDeviceInfo": "Device Info", "LabelDeviceInfo": "Device Info",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Device is available to...",
"LabelAudiblePreorders": "Audible Preorders",
"LabelDirectory": "Directory", "LabelDirectory": "Directory",
"LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata", "LabelDiscFromMetadata": "Disc from Metadata",

View file

@ -157,6 +157,16 @@ class Database {
return this.models.mediaItemShare 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')} */ /** @type {typeof import('./models/Device')} */
get deviceModel() { get deviceModel() {
return this.models.device return this.models.device
@ -345,6 +355,8 @@ class Database {
require('./models/Setting').init(this.sequelize) require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').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 }) return this.sequelize.sync({ force, alter: false })
} }

View file

@ -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()

View file

@ -894,6 +894,41 @@ class LibraryController {
.map((v) => v.trim().toLowerCase()) .map((v) => v.trim().toLowerCase())
.filter((v) => !!v) .filter((v) => !!v)
const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf) 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) res.json(shelves)
} }

View file

@ -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<void>}
*/
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<void>}
*/
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 }

View file

@ -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

View file

@ -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

View file

@ -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<Object[]>} 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<string>}
*/
get allowedRegions() {
return ALLOWED_REGIONS
}
}
module.exports = new AudibleLibrary()

View file

@ -31,6 +31,8 @@ const CacheController = require('../controllers/CacheController')
const ToolsController = require('../controllers/ToolsController') const ToolsController = require('../controllers/ToolsController')
const RSSFeedController = require('../controllers/RSSFeedController') const RSSFeedController = require('../controllers/RSSFeedController')
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
const AudibleController = require('../controllers/AudibleController')
const { rateLimit } = require('express-rate-limit')
const MiscController = require('../controllers/MiscController') const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController') 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.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)) 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 // Misc Routes
// //