Skip to content
← Public packages

@kentcdodds/audible

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

src/archive-remaining.ts

262 lines · 7.4 KB · TypeScript
import listLibrary from './library.ts'
import archiveToNas, { ArchiveEligibilityError } from './archive-to-nas.ts'
import libraryArchiveStatus from './library-archive-status.ts'
import type { SlimLibraryItem } from './types.ts'
import {
	filterNotOnNas,
	isDownloadable,
	type RemainingTitle,
} from './archive-remaining-helpers.ts'

export type { RemainingTitle } from './archive-remaining-helpers.ts'

export type ArchiveRemainingError = {
	asin: string
	title?: string
	error: string
	/** False for Plus / unsupported — do not retry. */
	retryable?: boolean
}

export type ArchiveRemainingResult = {
	ok: boolean
	/** Titles that are purchased/downloadable and not on the NAS. */
	total: number
	/** Successfully archived (not already-present skips counted separately). */
	archived: number
	/** Already on NAS / skipped without error. */
	skipped: number
	errors: ArchiveRemainingError[]
	/** Present when dryRun / countOnly — sample of remaining titles. */
	remaining?: RemainingTitle[]
	/** Library pages scanned while building the remaining set. */
	pagesScanned: number
	/** Library total_results from Audible (best-effort). */
	libraryTotalResults?: number
	/** Downloadable titles considered before NAS filter. */
	downloadableCount: number
	dryRun: boolean
	message: string
}

const PAGE_SIZE = 50
const MAX_PAGES = 200
const STATUS_BATCH = 40

/**
 * Scan Audible library pages and NAS status to find purchased titles not yet on the NAS.
 * Plus / unsupported formats are excluded (same eligibility as archive-to-nas).
 */
export async function listRemainingToArchive(input: {
	forceRefresh?: boolean
	/** Cap how many remaining titles to return (still computes exact total when possible). */
	limit?: number
} = {}): Promise<{
	total: number
	remaining: RemainingTitle[]
	pagesScanned: number
	libraryTotalResults?: number
	downloadableCount: number
}> {
	const downloadable: SlimLibraryItem[] = []
	let page = 1
	let pagesScanned = 0
	let libraryTotalResults: number | undefined

	while (page <= MAX_PAGES) {
		const lib = await listLibrary({
			numResults: PAGE_SIZE,
			page,
		})
		pagesScanned += 1
		if (typeof lib.totalResults === 'number') {
			libraryTotalResults = lib.totalResults
		}
		const items = Array.isArray(lib.items) ? lib.items : []
		for (const item of items) {
			if (isDownloadable(item)) downloadable.push(item)
		}
		if (items.length < PAGE_SIZE) break
		page += 1
	}

	const onNasByAsin = new Map<string, boolean>()
	for (let i = 0; i < downloadable.length; i += STATUS_BATCH) {
		const batch = downloadable.slice(i, i + STATUS_BATCH)
		const status = await libraryArchiveStatus({
			items: batch.map((item) => ({
				asin: item.asin,
				title: item.title!,
			})),
			forceRefresh: Boolean(input.forceRefresh),
		})
		for (const row of status.items ?? []) {
			if (row.asin) onNasByAsin.set(row.asin, Boolean(row.onNas))
		}
	}

	const remainingAll = filterNotOnNas(downloadable, onNasByAsin)

	const limit =
		typeof input.limit === 'number' && input.limit > 0
			? Math.floor(input.limit)
			: undefined
	const remaining = limit ? remainingAll.slice(0, limit) : remainingAll

	return {
		total: remainingAll.length,
		remaining,
		pagesScanned,
		libraryTotalResults,
		downloadableCount: downloadable.length,
	}
}

async function runPool<T, R>(
	items: T[],
	concurrency: number,
	fn: (item: T, index: number) => Promise<R>,
	shouldStop?: () => boolean,
): Promise<R[]> {
	const results: R[] = new Array(items.length)
	let next = 0
	const workers = Array.from(
		{ length: Math.max(1, Math.min(concurrency, items.length || 1)) },
		async () => {
			while (true) {
				if (shouldStop?.()) return
				const index = next++
				if (index >= items.length) return
				results[index] = await fn(items[index]!, index)
			}
		},
	)
	await Promise.all(workers)
	return results
}

/**
 * Count or archive purchased library titles that are not yet on the NAS.
 *
 * Scans Audible library pages, checks `./library-archive-status`, then either
 * returns the remaining set (`dryRun: true`) or runs `./archive-to-nas` with
 * low concurrency. Plus / unsupported titles are never archived.
 *
 * @param input - dryRun / limit / concurrency / forceRefresh
 * @returns `{ total, archived, skipped, errors, … }`
 *
 * @example
 * import archiveRemaining from 'kody:@kentcdodds/audible/archive-remaining'
 *
 * const count = await archiveRemaining({ dryRun: true })
 * // { total: 42, archived: 0, skipped: 0, errors: [], dryRun: true, … }
 */
export default async function archiveRemaining(input: {
	/** When true (default), only scan + count — do not archive. */
	dryRun?: boolean
	/** Alias of dryRun for agents. */
	countOnly?: boolean
	/** Max titles to archive in this run (after counting). */
	limit?: number
	/** Archive concurrency (1–2). Default 1. */
	concurrency?: number
	/** Bypass NAS status soft TTL while scanning. */
	forceRefresh?: boolean
	/** Optional abort hook for long agent runs. */
	shouldStop?: () => boolean
} = {}): Promise<ArchiveRemainingResult> {
	// Default dryRun/countOnly: safe count. Archive only when dryRun:false (or countOnly:false).
	const isDry =
		input.dryRun === true ||
		input.countOnly === true ||
		(input.dryRun !== false && input.countOnly !== false)
	const doArchive = !isDry

	const listed = await listRemainingToArchive({
		forceRefresh: input.forceRefresh,
		limit: doArchive ? input.limit : input.limit,
	})

	if (isDry) {
		return {
			ok: true,
			total: listed.total,
			archived: 0,
			skipped: 0,
			errors: [],
			remaining: listed.remaining,
			pagesScanned: listed.pagesScanned,
			libraryTotalResults: listed.libraryTotalResults,
			downloadableCount: listed.downloadableCount,
			dryRun: true,
			message:
				listed.total === 0
					? 'Nothing left to archive — all downloadable titles are on the NAS (or the library is empty).'
					: `${listed.total} downloadable title${listed.total === 1 ? '' : 's'} remaining to archive.`,
		}
	}

	const queue = listed.remaining
	let archived = 0
	let skipped = 0
	const errors: ArchiveRemainingError[] = []
	const concurrency = Math.max(1, Math.min(2, Math.floor(input.concurrency ?? 1)))

	await runPool(
		queue,
		concurrency,
		async (item) => {
			if (input.shouldStop?.()) return
			try {
				const result = await archiveToNas({
					asin: item.asin,
					title: item.title,
				})
				if (result.skipped) skipped += 1
				else if (result.ok) archived += 1
				else {
					errors.push({
						asin: item.asin,
						title: item.title,
						error: result.message || 'Archive failed',
						retryable: true,
					})
				}
			} catch (error) {
				if (error instanceof ArchiveEligibilityError) {
					errors.push({
						asin: item.asin,
						title: item.title,
						error: error.archiveHint || error.message,
						retryable: false,
					})
					return
				}
				errors.push({
					asin: item.asin,
					title: item.title,
					error: error instanceof Error ? error.message : String(error),
					retryable: true,
				})
			}
		},
		input.shouldStop,
	)

	const stopped = Boolean(input.shouldStop?.())
	return {
		ok: errors.length === 0,
		total: listed.total,
		archived,
		skipped,
		errors,
		remaining: undefined,
		pagesScanned: listed.pagesScanned,
		libraryTotalResults: listed.libraryTotalResults,
		downloadableCount: listed.downloadableCount,
		dryRun: false,
		message: stopped
			? `Stopped early — archived ${archived}, skipped ${skipped}, errors ${errors.length} (of ${listed.total} remaining).`
			: `Done — archived ${archived}, skipped ${skipped}, errors ${errors.length} (of ${listed.total} remaining).`,
	}
}