mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +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: {
|
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']
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
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',
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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())
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 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
|
||||||
//
|
//
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue