Skip to content
← Public packages

@kentcdodds/audible

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

src/nas-filename-candidates.ts

132 lines · 4.4 KB · TypeScript
/**
 * NAS flat-filename candidates for On-NAS / already_exists checks.
 *
 * Home `audiobook_library_filename` turns `:` / `:` into spaces. OpenAudible
 * (and some historical #58/#59 writes) turn them into `- ` instead, so exact
 * exists misses e.g. "Lion Vol. 1.m4b" when disk has "Lion- Vol. 1.m4b".
 *
 * After canonical + colon→`- ` home exists probes miss (or home errors),
 * optionally ask Media RSS for a *strict* Title.m4b / `Title - *.m4b` hit
 * (m4b only — never MP3). Left of ` - ` must equal the Audible title.
 */

import type { HomeAudiobookMcp } from './home-mcp.ts'
import { findNasM4bViaMediarss } from './mediarss-nas-match.ts'

/** Title with ASCII/fullwidth colons rewritten OpenAudible-style (`- `). */
export function openAudibleColonTitle(title: string): string | null {
	const trimmed = title?.trim()
	if (!trimmed || !/[::]/.test(trimmed)) return null
	const rewritten = trimmed.replace(/[::]/g, '- ').replace(/ {2,}/g, ' ').trim()
	return rewritten && rewritten !== trimmed ? rewritten : null
}

export type NasFilenameCandidates = {
	/** Flat name from home for the original title (canonical write target). */
	canonicalFilename: string
	/** Unique candidates to probe with audiobook_exists (canonical first). */
	candidates: string[]
}

/**
 * Build exists-check candidates: canonical home filename, plus OpenAudible-style
 * when the title contains a colon (re-run filename tool on colon→`- ` title).
 */
export async function listNasFilenameCandidates(
	home: Pick<HomeAudiobookMcp, 'audiobook_library_filename'>,
	title: string,
): Promise<NasFilenameCandidates> {
	const named = await home.audiobook_library_filename({ title })
	const canonicalFilename =
		typeof named?.filename === 'string' ? named.filename.trim() : ''
	if (!canonicalFilename) {
		throw new Error(`Home MCP did not return a filename for title "${title}".`)
	}
	const candidates = [canonicalFilename]
	const oaTitle = openAudibleColonTitle(title)
	if (oaTitle) {
		const oaNamed = await home.audiobook_library_filename({ title: oaTitle })
		const oaFilename =
			typeof oaNamed?.filename === 'string' ? oaNamed.filename.trim() : ''
		if (oaFilename && !candidates.includes(oaFilename)) {
			candidates.push(oaFilename)
		}
	}
	return { canonicalFilename, candidates }
}

export type NasExistsResolution = {
	canonicalFilename: string
	/** Matched disk name when onNas; otherwise canonical (intended write name). */
	filename: string
	onNas: boolean
	candidates: string[]
	/** How onNas was established when true. */
	matchSource?: 'home_exists' | 'mediarss_title_dash'
}

export type ResolveNasExistsOptions = {
	/** Override Media RSS lookup (tests). Default: findNasM4bViaMediarss. */
	findViaMediarss?: (title: string) => Promise<string | null>
}

/** True when any candidate exists; `filename` is the matched (or canonical) name. */
export async function resolveNasExists(
	home: Pick<
		HomeAudiobookMcp,
		'audiobook_library_filename' | 'audiobook_exists'
	>,
	title: string,
	options: ResolveNasExistsOptions = {},
): Promise<NasExistsResolution> {
	const trimmed = title.trim()
	const findViaMediarss = options.findViaMediarss ?? findNasM4bViaMediarss
	let canonicalFilename = trimmed ? `${trimmed}.m4b` : ''
	let candidates: string[] = canonicalFilename ? [canonicalFilename] : []

	try {
		const listed = await listNasFilenameCandidates(home, title)
		canonicalFilename = listed.canonicalFilename
		candidates = listed.candidates
		for (const filename of candidates) {
			try {
				const existing = await home.audiobook_exists({ filename })
				if (existing?.exists) {
					return {
						canonicalFilename,
						filename,
						onNas: true,
						candidates,
						matchSource: 'home_exists',
					}
				}
			} catch {
				// Probe failed — try remaining candidates / mediarss.
			}
		}
	} catch {
		// Home Unauthorized / missing — still allow strict mediarss Title - *.m4b.
	}

	// Title - subtitle / series suffix on disk (OpenAudible-style) — home has no
	// list/glob; Media RSS search with strict stem rules fills that gap for m4b.
	const mediarssHit = await findViaMediarss(trimmed)
	if (mediarssHit) {
		return {
			canonicalFilename,
			filename: mediarssHit,
			onNas: true,
			candidates: candidates.includes(mediarssHit)
				? candidates
				: [...candidates, mediarssHit],
			matchSource: 'mediarss_title_dash',
		}
	}

	return {
		canonicalFilename,
		filename: canonicalFilename,
		onNas: false,
		candidates,
	}
}