Skip to content
← 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,
		)
	},
}