Skip to content
← Public packages

@kentcdodds/audible

Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.

src/library-archive-status.ts

330 lines · 8.9 KB · TypeScript
import { getHomeAudiobookMcp } from './home-mcp.ts'
import { resolveNasExists } from './nas-filename-candidates.ts'
import {
	NAS_MAX_AGE_MS,
	NAS_SOFT_TTL_MS,
	loadNasStatusCache,
	lookupNasStatusCache,
	putNasStatusCacheEntry,
	saveNasStatusCache,
	type NasStatusCacheStore,
} from './nas-status-cache.ts'

export type LibraryArchiveStatusItem = {
	/** Present when the caller passed an ASIN (library / archive UI). */
	asin?: string
	title: string
	filename: string
	/** True when flat Title.m4b exists on the NAS (home audiobook_exists). */
	exists: boolean
	/** Alias of exists — clearer for UI copy ("On NAS"). */
	onNas: boolean
	/** Set when filename/exists lookup failed for this title. */
	error?: string
	/** Served from packageStorage cache (within max age). */
	cacheHit?: boolean
	/** Cache age past soft TTL but still within max age (served without home). */
	stale?: boolean
	checkedAt?: number
}

export type LibraryArchiveStatusCacheMeta = {
	softTtlMs: number
	maxAgeMs: number
	hits: number
	misses: number
	stale: number
	refreshed: number
}

export type LibraryArchiveStatusResult = {
	ok: boolean
	homeAvailable: boolean
	/** Home checks flat Title.m4b only — historical .mp3 siblings are not detected. */
	checksExtension: '.m4b'
	count: number
	onNasCount: number
	items: LibraryArchiveStatusItem[]
	message?: string
	/** True when any item was served past soft TTL without a home refresh. */
	stale?: boolean
	/** Cache policy + hit counters for agents / smoke. */
	cache?: LibraryArchiveStatusCacheMeta
}

function normalizeTitles(
	input: {
		titles?: string[]
		items?: Array<{ asin?: string; title?: string }>
	} = {},
): Array<{ asin?: string; title: string }> {
	const out: Array<{ asin?: string; title: string }> = []
	const seen = new Set<string>()

	for (const raw of input.items ?? []) {
		const title = typeof raw?.title === 'string' ? raw.title.trim() : ''
		if (!title) continue
		const asin = typeof raw?.asin === 'string' ? raw.asin.trim() : undefined
		const key = `${asin ?? ''}\0${title}`
		if (seen.has(key)) continue
		seen.add(key)
		out.push({ asin: asin || undefined, title })
	}

	for (const raw of input.titles ?? []) {
		const title = typeof raw === 'string' ? raw.trim() : ''
		if (!title) continue
		const key = `\0${title}`
		if (seen.has(key)) continue
		seen.add(key)
		out.push({ title })
	}

	return out
}

function itemFromCache(
	row: { asin?: string; title: string },
	lookup: NonNullable<ReturnType<typeof lookupNasStatusCache>>,
): LibraryArchiveStatusItem {
	const exists = Boolean(lookup.entry.onNas)
	return {
		asin: row.asin ?? lookup.entry.asin,
		title: row.title,
		filename: lookup.entry.nasFilename || '',
		exists,
		onNas: exists,
		cacheHit: true,
		stale: lookup.stale,
		checkedAt: lookup.entry.checkedAt,
	}
}

async function statusForTitle(
	home: ReturnType<typeof getHomeAudiobookMcp>,
	row: { asin?: string; title: string },
): Promise<LibraryArchiveStatusItem> {
	try {
		// Canonical home filename + OpenAudible colon→`- ` alternate; any hit → onNas.
		const resolved = await resolveNasExists(home, row.title)
		return {
			asin: row.asin,
			title: row.title,
			filename: resolved.filename,
			exists: resolved.onNas,
			onNas: resolved.onNas,
			checkedAt: Date.now(),
		}
	} catch (error) {
		return {
			asin: row.asin,
			title: row.title,
			filename: '',
			exists: false,
			onNas: false,
			error: error instanceof Error ? error.message : String(error),
		}
	}
}

/**
 * Map owned titles (or slim library items) to NAS archive status via home MCP.
 * Use when the Library / Archive UI needs On NAS vs Save-to-NAS without N+1 from
 * the browser — batch `audiobook_library_filename` + `audiobook_exists` server-side.
 * Home has no list-all tool; this only checks the titles you pass. Exists is
 * flat `Title.m4b` only (historical `.mp3` files are not detected), plus an
 * OpenAudible-style colon→`- ` filename alternate when the title contains `:`,
 * then a strict Media RSS `Title - *.m4b` fallback (m4b only; left of ` - `
 * equals Audible title — including full “Fablehaven, Book N”). Home errors
 * still allow the mediarss path.
 *
 * Results are heavily cached in packageStorage (soft TTL ~60s, max age ~30d).
 * Within soft TTL, home is skipped. Past soft TTL (within max age) stale is
 * returned immediately unless `forceRefresh` / `revalidate` is set — then home
 * runs and the cache is updated. Successful `./archive-to-nas` write-through
 * marks that title onNas immediately.
 *
 * @param input - titles and/or items with asin + title; optional forceRefresh
 * @returns Per-title filename + onNas/exists (and homeAvailable / cache meta)
 *
 * @example
 * import libraryArchiveStatus from 'kody:@kentcdodds/audible/library-archive-status'
 *
 * const status = await libraryArchiveStatus({
 *   items: [{ asin: 'B002UZKI96', title: 'Hatchet' }],
 * })
 * // { ok: true, items: [{ title: 'Hatchet', filename: 'Hatchet.m4b', onNas: true, … }] }
 */
export default async function libraryArchiveStatus(
	input: {
		titles?: string[]
		items?: Array<{ asin?: string; title?: string }>
		/** Bypass soft TTL / stale serve and hit home MCP for every title. */
		forceRefresh?: boolean
		/** Alias of forceRefresh (pull-to-refresh / ?refresh=1). */
		revalidate?: boolean
	} = {},
): Promise<LibraryArchiveStatusResult> {
	const rows = normalizeTitles(input)
	const forceRefresh = Boolean(input.forceRefresh || input.revalidate)
	const cacheMeta: LibraryArchiveStatusCacheMeta = {
		softTtlMs: NAS_SOFT_TTL_MS,
		maxAgeMs: NAS_MAX_AGE_MS,
		hits: 0,
		misses: 0,
		stale: 0,
		refreshed: 0,
	}

	if (!rows.length) {
		return {
			ok: true,
			homeAvailable: true,
			checksExtension: '.m4b',
			count: 0,
			onNasCount: 0,
			items: [],
			stale: false,
			cache: cacheMeta,
			message: 'No titles provided.',
		}
	}

	const store = await loadNasStatusCache()
	const now = Date.now()
	const items: LibraryArchiveStatusItem[] = new Array(rows.length)
	const needHome: Array<{ index: number; row: { asin?: string; title: string } }> =
		[]

	for (let i = 0; i < rows.length; i++) {
		const row = rows[i]!
		if (!forceRefresh) {
			const hit = lookupNasStatusCache(store, row, now)
			if (hit?.fresh) {
				items[i] = itemFromCache(row, hit)
				cacheMeta.hits++
				continue
			}
			if (hit?.stale) {
				// SWR: serve stale immediately; client / ?refresh=1 revalidates.
				items[i] = itemFromCache(row, hit)
				cacheMeta.hits++
				cacheMeta.stale++
				continue
			}
		}
		cacheMeta.misses++
		needHome.push({ index: i, row })
	}

	const servedStale = cacheMeta.stale > 0

	if (!needHome.length) {
		const onNasCount = items.filter((i) => i.onNas).length
		return {
			ok: true,
			homeAvailable: true,
			checksExtension: '.m4b',
			count: items.length,
			onNasCount,
			items,
			stale: servedStale,
			cache: cacheMeta,
		}
	}

	let home: ReturnType<typeof getHomeAudiobookMcp>
	try {
		home = getHomeAudiobookMcp()
	} catch (error) {
		const message = error instanceof Error ? error.message : String(error)
		// Prefer stale/expired-miss fallbacks only for titles we already filled;
		// fill remaining with home-unavailable errors (or last-chance expired skip).
		for (const { index, row } of needHome) {
			const hit = lookupNasStatusCache(store, row, now)
			if (hit) {
				items[index] = itemFromCache(row, hit)
				cacheMeta.hits++
				cacheMeta.misses = Math.max(0, cacheMeta.misses - 1)
				if (hit.stale) cacheMeta.stale++
			} else {
				items[index] = {
					asin: row.asin,
					title: row.title,
					filename: '',
					exists: false,
					onNas: false,
					error: message,
				}
			}
		}
		const onNasCount = items.filter((i) => i?.onNas).length
		return {
			ok: false,
			homeAvailable: false,
			checksExtension: '.m4b',
			count: items.length,
			onNasCount,
			items,
			stale: cacheMeta.stale > 0,
			cache: cacheMeta,
			message,
		}
	}

	const concurrency = 8
	let cursor = 0
	const refreshed: LibraryArchiveStatusItem[] = []
	async function worker() {
		while (cursor < needHome.length) {
			const index = cursor++
			const job = needHome[index]!
			const item = await statusForTitle(home, job.row)
			items[job.index] = item
			refreshed.push(item)
			cacheMeta.refreshed++
		}
	}
	await Promise.all(
		Array.from({ length: Math.min(concurrency, needHome.length) }, () =>
			worker(),
		),
	)

	let mutated = false
	const nextStore: NasStatusCacheStore = {
		version: 1,
		byTitle: { ...store.byTitle },
		byAsin: { ...store.byAsin },
	}
	for (const item of refreshed) {
		if (item.error && !item.filename) continue
		putNasStatusCacheEntry(nextStore, {
			title: item.title,
			asin: item.asin,
			nasFilename: item.filename,
			onNas: item.onNas,
			checkedAt: item.checkedAt ?? Date.now(),
		})
		mutated = true
	}
	if (mutated) {
		try {
			await saveNasStatusCache(nextStore)
		} catch {
			// Cache write is best-effort; status result still returns.
		}
	}

	const onNasCount = items.filter((i) => i.onNas).length
	return {
		ok: true,
		homeAvailable: true,
		checksExtension: '.m4b',
		count: items.length,
		onNasCount,
		items,
		stale: servedStale,
		cache: cacheMeta,
	}
}