← Public packages
@kentcdodds/audible
Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.
src/app.ts
489 lines · 13.0 KB · TypeScript/**
* Hosted Audible package app — Connect / Library / Wishlist / Archive.
* Mount-aware via packageContext; never echoes secret values.
*/
import startAuth from './start-auth.ts'
import completeAuth from './complete-auth.ts'
import authStatus from './auth-status.ts'
import listLibrary from './library.ts'
import wishlist from './wishlist.ts'
import archiveToNas from './archive-to-nas.ts'
import archiveRemaining from './archive-remaining.ts'
import { ArchiveEligibilityError } from './archive-eligibility.ts'
import libraryArchiveStatus from './library-archive-status.ts'
import { listLocaleCodes } from './locales.ts'
import { renderAppPage, type PageFlash } from './app/page.ts'
import { renderClientJs } from './app/client-js.ts'
import { appPaths, pathnameOf } from './app/urls.ts'
function html(body: string, status = 200): Response {
return new Response(body, {
status,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
},
})
}
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
},
})
}
function wantsJson(request: Request): boolean {
const accept = request.headers.get('accept') || ''
const contentType = request.headers.get('content-type') || ''
return (
accept.includes('application/json') ||
contentType.includes('application/json') ||
new URL(request.url).searchParams.get('format') === 'json'
)
}
async function readBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
const text = await request.text()
if (!text) return {}
const parsed = JSON.parse(text) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
}
if (
contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')
) {
const form = await request.formData()
const out: Record<string, unknown> = {}
for (const [key, value] of form.entries()) {
if (typeof value === 'string') out[key] = value
}
return out
}
const text = await request.text()
if (!text) return {}
try {
const parsed = JSON.parse(text) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// ignore
}
return {}
}
async function chipFromStatus() {
try {
const status = await authStatus()
if (status.pendingSecret) {
return {
state: 'pending' as const,
label: 'Needs connect',
raw: status,
}
}
if (status.signingReady) {
return {
state: 'connected' as const,
label: 'Connected',
raw: status,
}
}
return {
state: 'needs-reauth' as const,
label: 'Needs re-auth',
raw: status,
}
} catch {
return {
state: 'pending' as const,
label: 'Status unknown',
raw: null,
}
}
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
/**
* Package app fetch handler (`kody.app.entry`).
*/
export default {
async fetch(request: Request): Promise<Response> {
const { appBasePath } = appPaths()
const path = pathnameOf(request, appBasePath)
const method = request.method.toUpperCase()
if (method === 'GET' && path === '/manifest.webmanifest') {
return json({
name: 'Audible library',
short_name: 'Audible',
start_url: `${appBasePath}/`,
scope: `${appBasePath}/`,
display: 'standalone',
background_color: '#0f1115',
theme_color: '#0f1115',
description:
'Personal Audible library connect, browse, wishlist, and Save-to-NAS via home MCP.',
})
}
if (method === 'GET' && path === '/app.js') {
return new Response(renderClientJs(), {
headers: {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-store',
},
})
}
if (method === 'GET' && (path === '/' || path === '')) {
const chip = await chipFromStatus()
return html(
renderAppPage({
authChip: { state: chip.state, label: chip.label },
}),
)
}
if (method === 'GET' && path === '/api/status') {
const chip = await chipFromStatus()
return json({
state: chip.state,
label: chip.label,
pendingSecret: chip.raw?.pendingSecret ?? true,
signingReady: chip.raw?.signingReady ?? false,
locale: chip.raw && 'locale' in chip.raw ? chip.raw.locale : null,
supportedLocales: listLocaleCodes(),
})
}
if (method === 'GET' && path === '/api/library') {
try {
const url = new URL(request.url)
const q = url.searchParams.get('q')?.trim() || ''
const pageNum = Math.max(
1,
Number.parseInt(url.searchParams.get('page') || '1', 10) || 1,
)
const numResults = Math.min(
Math.max(
Number.parseInt(url.searchParams.get('numResults') || '50', 10) ||
50,
1,
),
100,
)
const forceRefresh =
url.searchParams.get('refresh') === '1' ||
url.searchParams.get('revalidate') === '1' ||
url.searchParams.get('forceRefresh') === '1'
const page = await listLibrary({
title: q || undefined,
numResults,
page: pageNum,
})
const items = Array.isArray(page.items) ? page.items : []
let archiveStatus = null
try {
archiveStatus = await libraryArchiveStatus({
items: items.map((item) => ({
asin: item.asin,
title: item.title,
})),
forceRefresh,
})
const byAsin = new Map(
(archiveStatus.items ?? [])
.filter((row) => row.asin)
.map((row) => [row.asin, row]),
)
const byTitle = new Map(
(archiveStatus.items ?? []).map((row) => [row.title, row]),
)
page.items = items.map((item) => {
const row =
(item.asin && byAsin.get(item.asin)) ||
(item.title ? byTitle.get(item.title) : undefined)
if (!row) return item
return {
...item,
nasFilename: row.filename || undefined,
onNas: Boolean(row.onNas),
nasCacheHit: Boolean(row.cacheHit),
nasStale: Boolean(row.stale),
}
})
} catch {
// Library still loads if home MCP is down — Save to NAS stays available.
}
const totalResults =
typeof page.totalResults === 'number' ? page.totalResults : items.length
// Prefer full-page heuristic — Audible total_results can under-report.
const hasMore = items.length >= numResults
return json({
...page,
page: pageNum,
numResults,
hasMore,
archiveStatus: archiveStatus
? {
homeAvailable: archiveStatus.homeAvailable,
checksExtension: archiveStatus.checksExtension,
onNasCount: archiveStatus.onNasCount,
count: archiveStatus.count,
stale: Boolean(archiveStatus.stale),
cache: archiveStatus.cache ?? null,
}
: null,
})
} catch (error) {
return json({ error: errorMessage(error) }, 401)
}
}
if (method === 'GET' && path === '/api/wishlist') {
try {
const url = new URL(request.url)
const pageNum = Math.max(
0,
Number.parseInt(url.searchParams.get('page') || '0', 10) || 0,
)
const numResults = Math.min(
Math.max(
Number.parseInt(url.searchParams.get('numResults') || '50', 10) ||
50,
1,
),
50,
)
const page = await wishlist({
action: 'list',
numResults,
page: pageNum,
})
const products = Array.isArray(page.products) ? page.products : []
const totalResults =
typeof page.totalResults === 'number'
? page.totalResults
: products.length
const hasMore = products.length >= numResults
return json({
...page,
page: pageNum,
numResults,
hasMore,
})
} catch (error) {
return json({ error: errorMessage(error) }, 401)
}
}
if (method === 'POST' && path === '/api/wishlist') {
try {
const body = await readBody(request)
const action =
body.action === 'add' || body.action === 'remove'
? body.action
: null
if (!action) return json({ error: 'action must be add or remove' }, 400)
const asin = String(body.asin ?? '').trim()
if (!asin) return json({ error: 'asin is required' }, 400)
const result = await wishlist({ action, asin })
return json(result)
} catch (error) {
return json({ error: errorMessage(error) }, 400)
}
}
if (method === 'GET' && path === '/api/archive-remaining') {
try {
const url = new URL(request.url)
const forceRefresh =
url.searchParams.get('refresh') === '1' ||
url.searchParams.get('forceRefresh') === '1'
const limitRaw = url.searchParams.get('limit')
const limit = limitRaw
? Math.max(1, Number.parseInt(limitRaw, 10) || 0) || undefined
: undefined
const result = await archiveRemaining({
dryRun: true,
forceRefresh,
limit,
})
return json(result)
} catch (error) {
return json({ error: errorMessage(error) }, 401)
}
}
if (method === 'POST' && path === '/api/archive') {
const body = await readBody(request)
const asin = String(body.asin ?? '').trim()
if (!asin) return json({ error: 'asin is required' }, 400)
const title = String(body.title ?? '').trim()
const quality = body.quality === 'Normal' ? 'Normal' : 'High'
const overwrite =
body.overwrite === true ||
body.overwrite === 'true' ||
body.overwrite === 'on' ||
body.overwrite === '1'
try {
const result = await archiveToNas({
asin,
title: title || undefined,
quality,
overwrite,
})
return json(result)
} catch (error) {
const message = errorMessage(error)
if (error instanceof ArchiveEligibilityError) {
return json(
{
ok: false,
asin,
error: message,
message,
code: error.code,
archiveEligibility: error.archiveEligibility,
archiveHint: error.archiveHint,
retryable: false,
},
400,
)
}
return json({ ok: false, asin, error: message, message }, 400)
}
}
if (method === 'POST' && path === '/auth/start') {
try {
const body = await readBody(request)
const result = await startAuth({
locale: String(body.locale ?? 'us'),
withUsername: Boolean(body.withUsername),
})
if (wantsJson(request)) return json(result)
const chip = await chipFromStatus()
return html(
renderAppPage({
authChip: { state: chip.state, label: chip.label },
flash: {
kind: 'info',
title: 'Amazon login ready',
body: `Open the login URL (session ${result.authSessionId}), finish Amazon sign-in, then paste the maplanding URL below.`,
},
}),
)
} catch (error) {
const message = errorMessage(error)
if (wantsJson(request)) return json({ error: message }, 400)
return html(
renderAppPage({
flash: { kind: 'error', title: 'Could not start auth', body: message },
}),
400,
)
}
}
if (method === 'POST' && path === '/auth/complete') {
try {
const body = await readBody(request)
const redirectUrl = String(body.redirectUrl ?? '').trim()
if (!redirectUrl) {
const message = 'redirectUrl is required'
if (wantsJson(request)) return json({ error: message }, 400)
return html(
renderAppPage({
flash: {
kind: 'error',
title: 'Missing maplanding URL',
body: message,
},
}),
400,
)
}
const result = await completeAuth({
redirectUrl,
authSessionId: body.authSessionId
? String(body.authSessionId)
: undefined,
})
// Never include secret values — result already public-only
if (wantsJson(request)) {
return json(
{
ok: result.ok,
message: result.message,
secretName: result.secretName,
secretScope:
'secretScope' in result ? result.secretScope : undefined,
authStatus: result.authStatus,
setupUrl: 'setupUrl' in result ? result.setupUrl : undefined,
error: result.ok ? undefined : ('reason' in result ? result.reason : undefined),
},
result.ok ? 200 : 400,
)
}
const flash: PageFlash = result.ok
? {
kind: 'success',
title: 'Connected',
body: result.message,
}
: {
kind: 'error',
title: 'Almost connected',
body: result.message,
}
const chip = await chipFromStatus()
return html(
renderAppPage({
flash,
authChip: { state: chip.state, label: chip.label },
}),
result.ok ? 200 : 400,
)
} catch (error) {
const message = errorMessage(error)
if (wantsJson(request)) return json({ error: message }, 400)
return html(
renderAppPage({
flash: {
kind: 'error',
title: 'Connect failed',
body: message,
},
}),
400,
)
}
}
return html(
renderAppPage({
flash: {
kind: 'error',
title: 'Not found',
body: `No route for ${method} ${path}`,
},
}),
404,
)
},
}