mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 01:41:35 +00:00
Enable audible scrape of personal user account to list preorders
This commit is contained in:
parent
3ccdcaec1a
commit
792210d8cd
16 changed files with 1316 additions and 2 deletions
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -32,12 +32,17 @@
|
|||
<cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" />
|
||||
</template>
|
||||
</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 class="relative">
|
||||
<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` }">
|
||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</h2>
|
||||
<h2 :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] || shelf.label }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ export default {
|
|||
id: 'config-authentication',
|
||||
title: this.$strings.HeaderAuthentication,
|
||||
path: '/config/authentication'
|
||||
},
|
||||
{
|
||||
id: 'config-audible',
|
||||
title: 'Audible',
|
||||
path: '/config/audible'
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
67
client/components/cards/AudiblePreorderCard.vue
Normal file
67
client/components/cards/AudiblePreorderCard.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
225
client/pages/config/audible.vue
Normal file
225
client/pages/config/audible.vue
Normal 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 }} • 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
261
server/controllers/AudibleController.js
Normal file
261
server/controllers/AudibleController.js
Normal 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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
131
server/migrations/v2.33.2-setup-audible.js
Normal file
131
server/migrations/v2.33.2-setup-audible.js
Normal 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 }
|
||||
170
server/models/AudibleAccount.js
Normal file
170
server/models/AudibleAccount.js
Normal 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
|
||||
147
server/models/AudibleBook.js
Normal file
147
server/models/AudibleBook.js
Normal 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
|
||||
229
server/providers/AudibleLibrary.js
Normal file
229
server/providers/AudibleLibrary.js
Normal 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()
|
||||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue