← 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 · TypeScriptimport 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).`,
}
}