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