← 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 · TypeScriptimport { 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,
}
}