Skip to content
← Public packages

@kentcdodds/audible

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

src/mediarss-nas-match.ts

127 lines · 4.0 KB · TypeScript
/**
 * Optional Media RSS soft-match for On-NAS when home exact/colon exists miss.
 *
 * Strict rules only (no fuzzy accept):
 * - mediaRoot audiobooks
 * - .m4b only (never treat .mp3/.m4a as archived)
 * - stem equals title (ci) OR left side of first ` - ` equals Audible title
 *   (also `Title- ` OpenAudible compact form)
 * - For “Series, Book N” (e.g. Fablehaven, Book 2), left must equal the *full*
 *   Audible title — not merely the series name before the first comma
 * - Does not equate Full-Cast / edition variants to a classic Audible title
 *
 * Pure helpers stay free of `kody:runtime` so unit tests run locally.
 */

export type MediarssAudiobookHit = {
	mediaRoot?: string
	relativePath?: string
	filename?: string
	mimeType?: string
	title?: string
}

export function stemFromFilename(filename: string): string {
	const base = filename.trim().split(/[/\\]/).pop() || filename.trim()
	return base.replace(/\.m4b$/i, '')
}

/** Edition / cast markers that must not soft-match a classic Audible title. */
const EDITION_VARIANT_RE =
	/\b(full[\s-]?cast|dramatized|graphic audio|anniversary edition|enhanced edition)\b/i

function titleHasEditionVariant(title: string): boolean {
	return EDITION_VARIANT_RE.test(title)
}

/**
 * Left side of the first ` - ` separator in a stem (OpenAudible subtitle form).
 * Returns null when there is no spaced dash separator.
 */
export function leftOfTitleDash(stem: string): string | null {
	const idx = stem.indexOf(' - ')
	if (idx <= 0) return null
	return stem.slice(0, idx)
}

/**
 * True when disk stem is an exact or strict Title-dash match for Audible title.
 * Left of ` - ` must equal the full Audible title (ci) — for “Fablehaven, Book N”
 * that means left === `Fablehaven, Book N`, not just `Fablehaven`.
 * Rejects Full-Cast / dramatized stems unless the Audible title itself names that edition.
 * Never accepts non-.m4b.
 */
export function isStrictTitleDashOrExactM4b(
	audibleTitle: string,
	filename: string,
): boolean {
	const title = audibleTitle.trim()
	if (!title || !filename.trim().toLowerCase().endsWith('.m4b')) return false
	const stem = stemFromFilename(filename)
	const t = title.toLowerCase()
	const s = stem.toLowerCase()
	if (s === t) return true

	const left = leftOfTitleDash(s)
	const leftEqualsTitle = left !== null && left === t
	const compactDash = s.startsWith(`${t}- `)
	if (!leftEqualsTitle && !compactDash) return false

	// "Classic Book N" must not match "Title - Full-Cast Edition" (or similar).
	if (!titleHasEditionVariant(title) && titleHasEditionVariant(stem)) {
		return false
	}
	return true
}

export function pickStrictMediarssM4bMatch(
	audibleTitle: string,
	hits: MediarssAudiobookHit[],
): string | null {
	for (const hit of hits) {
		if (hit.mediaRoot && hit.mediaRoot !== 'audiobooks') continue
		const filename =
			(typeof hit.filename === 'string' && hit.filename.trim()) ||
			(typeof hit.relativePath === 'string'
				? hit.relativePath.split(/[/\\]/).pop() || ''
				: '')
		if (!filename) continue
		if (isStrictTitleDashOrExactM4b(audibleTitle, filename)) {
			return /\.m4b$/i.test(filename) ? filename : `${filename}.m4b`
		}
	}
	return null
}

type MediarssMcp = {
	search_media?: (args: {
		query: string
		limit?: number
	}) => Promise<{ results?: MediarssAudiobookHit[]; total?: number }>
}

/**
 * Search Media RSS for a strict Title.m4b / Title - *.m4b hit.
 * Returns matched filename or null when unavailable / no safe match.
 */
export async function findNasM4bViaMediarss(
	title: string,
): Promise<string | null> {
	const query = title.trim()
	if (!query) return null
	let mediarss: MediarssMcp | null = null
	try {
		const { kody } = await import('kody:runtime')
		const mcp = (kody as { mcp?: Record<string, unknown> }).mcp
		mediarss = (mcp?.mediarss as MediarssMcp | undefined) ?? null
	} catch {
		return null
	}
	if (!mediarss || typeof mediarss.search_media !== 'function') return null
	try {
		const search = await mediarss.search_media({ query, limit: 12 })
		return pickStrictMediarssM4bMatch(query, search.results ?? [])
	} catch {
		return null
	}
}