mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 01:41:35 +00:00
229 lines
7.1 KiB
JavaScript
229 lines
7.1 KiB
JavaScript
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()
|