← 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
}
}