diff --git a/src/lib/SimpleFileType.ts b/src/lib/SimpleFileType.ts index 3ac515e..198142e 100644 --- a/src/lib/SimpleFileType.ts +++ b/src/lib/SimpleFileType.ts @@ -1,9 +1,23 @@ +/** + * The `fileType` vocabulary — see METADATA_KEYS.md §1. + * + * ⚠ INVARIANT: a value belongs here only if an `(extension, mimeType)` lookup + * can decide it on its own. Nothing that requires knowing what the content + * *is* goes in this field — that is `contentKind`'s job — and nothing that + * decides *which app gets the record* depends on it — that is `domain`'s job + * (METADATA_KEYS.md §14.16). + * + * `card` is the one deliberate exception: it describes no bytes at all and is + * therefore never produced by the tables here, only stamped by a card feeder. + */ export type SimpleFileType = 'audio' | 'video' + | 'image' | 'document' | 'archive' | 'subtitle' | 'torrent' + | 'card' | 'other' - | 'undefined'; \ No newline at end of file + | 'undefined'; diff --git a/src/lib/config/DomainMapping.ts b/src/lib/config/DomainMapping.ts new file mode 100644 index 0000000..0baff06 --- /dev/null +++ b/src/lib/config/DomainMapping.ts @@ -0,0 +1,74 @@ +/** + * `contentKind` → `domain` — the routing table from METADATA_KEYS.md §1. + * + * `domain` answers "which application is this record destined for?" and is the + * key every client filters its catalogue on. It is **stored, not derived on + * read**: that is what lets the `contentKind` vocabulary stay open, because a + * peer that has never heard of a kind still routes the record correctly + * without having to interpret the token. + * + * ⚠ This binding is **permanent and one-way**. Add kinds freely; never re-map + * an existing kind to a different domain. Because the value is stored, two + * peers on different versions that disagree about where a kind belongs produce + * a split-brain that redeploying cannot heal — the wrong domain is already + * written into records across the mesh. If a kind has to move, mint a new kind. + */ +export type Domain = + 'film' + | 'tv' + | 'music' + | 'spoken' + | 'literature' + | 'science'; + +/** + * The one-way binding. A kind that is absent here has no domain, which means + * the record is **not independently routable** — correct for sidecars + * (subtitles, artwork) and for un-anchored hits, a producer bug for anything + * else. + * + * ⚠ `pack` is deliberately absent: a season pack and an album release are the + * same structural thing, so the kind alone cannot tell `tv` from `music`. The + * writer stamps `pack`'s domain from its own context — see + * {@link domainForContentKind}'s contract. + */ +export const domainByContentKind: { [contentKind: string]: Domain } = { + // film / tv + movie: 'film', + series: 'tv', + episode: 'tv', + // music + track: 'music', + album: 'music', + artist: 'music', + musicVideo: 'music', + djMix: 'music', + liveSet: 'music', + // spoken + podcast: 'spoken', + podcastEpisode: 'spoken', + // literature + book: 'literature', + // An audiobook's identity is the book — same author, same ISBN family, + // same series as the ebook — so meta-read can group the two editions on + // one page. The player does not decide the domain. + audiobook: 'literature', + comic: 'literature', + manga: 'literature', + magazine: 'literature', + // science + paper: 'science', +}; + +/** + * Resolve the domain a `contentKind` belongs to. + * + * Returns `undefined` for `pack` (not derivable from the kind — the writer + * supplies it), for sidecar/format kinds that route nowhere, and for any kind + * this build has never heard of. A caller that already knows the domain from + * its own context must prefer that over this table. + */ +export function domainForContentKind(contentKind: string | undefined | null): Domain | undefined { + if (!contentKind) return undefined; + return domainByContentKind[contentKind.trim()]; +} diff --git a/src/lib/config/ExtentionsMapping.ts b/src/lib/config/ExtentionsMapping.ts index d87c7b9..1c012ef 100644 --- a/src/lib/config/ExtentionsMapping.ts +++ b/src/lib/config/ExtentionsMapping.ts @@ -1,5 +1,14 @@ import {SimpleFileType} from "../SimpleFileType.js"; +/** + * Extension → `fileType`. Half of the canonical classification table + * documented in METADATA_KEYS.md §1 (the other half is + * {@link mimeTypeMappings}); this file is the source of truth and the doc + * mirrors it. + * + * An extension absent from this map resolves to `other`, which makes the file + * invisible to every typed query — so a missing entry is not a cosmetic gap. + */ export const extensionMappings: { [key: string]: SimpleFileType } = { // Audio 'mp3': 'audio', @@ -7,7 +16,19 @@ export const extensionMappings: { [key: string]: SimpleFileType } = { 'flac': 'audio', 'aac': 'audio', 'ogg': 'audio', + 'oga': 'audio', + 'opus': 'audio', 'm4a': 'audio', + // `m4b` is the audiobook container — without it `contentKind=audiobook` + // had no extension that resolved to `audio` (METADATA_KEYS.md §14.15). + 'm4b': 'audio', + 'wma': 'audio', + 'aiff': 'audio', + 'aif': 'audio', + 'ape': 'audio', + 'wv': 'audio', + 'dsf': 'audio', + 'mka': 'audio', // Video 'mp4': 'video', 'mkv': 'video', @@ -27,6 +48,8 @@ export const extensionMappings: { [key: string]: SimpleFileType } = { 'ts': 'video', 'vob': 'video', 'ogm': 'video', + 'ogv': 'video', + 'asf': 'video', 'divx': 'video', 'xvid': 'video', // Document @@ -37,31 +60,64 @@ export const extensionMappings: { [key: string]: SimpleFileType } = { 'pptx': 'document', 'xls': 'document', 'xlsx': 'document', + 'odt': 'document', + 'ods': 'document', + 'odp': 'document', 'txt': 'document', + 'md': 'document', 'rtf': 'document', - // Images (are document) - 'jpg': 'document', - 'jpeg': 'document', - 'png': 'document', - 'gif': 'document', - 'bmp': 'document', - 'tif': 'document', - 'tiff': 'document', - 'svg': 'document', - 'webp': 'document', + // Document — literature formats. Their absence blocked meta-read outright + // (METADATA_KEYS.md §14.15). + 'epub': 'document', + 'mobi': 'document', + 'azw': 'document', + 'azw3': 'document', + 'djvu': 'document', + // Images + // ⚠ These used to map to `document` ("Images (are document)"), which meant + // the `image` bucket was unreachable from this table while Rust feeders + // wrote it anyway. A consumer asking for documents must not receive + // posters — see METADATA_KEYS.md §14.15. + 'jpg': 'image', + 'jpeg': 'image', + 'png': 'image', + 'gif': 'image', + 'bmp': 'image', + 'tif': 'image', + 'tiff': 'image', + 'svg': 'image', + 'webp': 'image', + 'avif': 'image', + 'heic': 'image', + 'heif': 'image', + 'ico': 'image', // Archive 'zip': 'archive', 'rar': 'archive', '7z': 'archive', 'tar': 'archive', + 'tgz': 'archive', 'gz': 'archive', 'bz2': 'archive', 'xz': 'archive', + 'zst': 'archive', + // Archive — comic containers. A `.cbz` is literally a zip, so `archive` is + // the honest byte-level answer; `contentKind=comic` + `domain=literature` + // are what route it to meta-read (METADATA_KEYS.md §14.16). + 'cbz': 'archive', + 'cbr': 'archive', //subtitles 'srt': 'subtitle', 'sub': 'subtitle', 'sbv': 'subtitle', 'vtt': 'subtitle', + // ⚠ ASS/SSA is what meta-watch renders through libass — the client's + // primary subtitle format was the one this classifier did not know. + 'ass': 'subtitle', + 'ssa': 'subtitle', + 'ttml': 'subtitle', + 'dfxp': 'subtitle', + 'smi': 'subtitle', //torrent 'torrent': 'torrent', -}; \ No newline at end of file +}; diff --git a/src/lib/config/MimeTypeMapping.ts b/src/lib/config/MimeTypeMapping.ts index 48ea892..b6ce6e0 100644 --- a/src/lib/config/MimeTypeMapping.ts +++ b/src/lib/config/MimeTypeMapping.ts @@ -1,11 +1,32 @@ import {SimpleFileType} from "../SimpleFileType.js"; +/** + * MIME type → `fileType`. The other half of the canonical classification + * table (see {@link extensionMappings} and METADATA_KEYS.md §1). + * + * ⚠ Resolution is conservative on purpose: when a sniffed MIME maps to a + * definite value that *disagrees* with the extension's value, + * `FileTypeConfigurable.getFileType` returns `undefined` rather than guessing + * — a wrong `fileType` routes a file to a handler that cannot open it. Adding + * a mapping here therefore also creates a new way to disagree; only add ones + * whose extension counterpart agrees. + */ export const mimeTypeMappings: { [key: string]: SimpleFileType } = { 'text/plain': 'undefined',//text could be anything document or subtitle or other // Audio MIME types 'audio/mpeg': 'audio', 'audio/wav': 'audio', + 'audio/x-wav': 'audio', 'audio/flac': 'audio', + 'audio/ogg': 'audio', + 'audio/opus': 'audio', + 'audio/aac': 'audio', + // ftyp brands `M4A `/`M4B ` sniff as audio/mp4 while a real video mp4 + // sniffs as video/mp4, so this does not collide with the `mp4` extension. + 'audio/mp4': 'audio', + 'audio/x-ms-wma': 'audio', + 'audio/x-aiff': 'audio', + 'audio/x-ape': 'audio', // Video MIME types 'video/mp4': 'video', 'video/mpeg': 'video', @@ -13,8 +34,12 @@ export const mimeTypeMappings: { [key: string]: SimpleFileType } = { 'video/x-msvideo': 'video', 'video/x-flv': 'video', 'video/x-ms-wmv': 'video', + 'video/x-ms-asf': 'video', 'video/x-matroska': 'video', 'video/webm': 'video', + 'video/ogg': 'video', + 'video/3gpp': 'video', + 'video/mp2t': 'video', // Document MIME types 'application/pdf': 'document', 'application/msword': 'document', @@ -23,20 +48,47 @@ export const mimeTypeMappings: { [key: string]: SimpleFileType } = { 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', 'application/vnd.ms-excel': 'document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', - 'image/jpeg': 'document', - 'image/png': 'document', - 'image/gif': 'document', + 'application/vnd.oasis.opendocument.text': 'document', + 'application/vnd.oasis.opendocument.spreadsheet': 'document', + 'application/vnd.oasis.opendocument.presentation': 'document', + 'application/rtf': 'document', + // Literature containers (METADATA_KEYS.md §14.15) + 'application/epub+zip': 'document', + 'application/x-mobipocket-ebook': 'document', + 'image/vnd.djvu': 'document', + 'text/markdown': 'document', + // Image MIME types — no longer folded into `document` (§14.15) + 'image/jpeg': 'image', + 'image/png': 'image', + 'image/gif': 'image', + 'image/webp': 'image', + 'image/bmp': 'image', + 'image/tiff': 'image', + 'image/svg+xml': 'image', + 'image/avif': 'image', + 'image/heic': 'image', + 'image/x-icon': 'image', // Archive MIME types 'application/zip': 'archive', 'application/x-rar-compressed': 'archive', + 'application/vnd.rar': 'archive', 'application/x-7z-compressed': 'archive', 'application/gzip': 'archive', + 'application/x-bzip2': 'archive', + 'application/x-xz': 'archive', + 'application/zstd': 'archive', 'application/x-tar': 'archive', + // Comic containers sniff as their underlying zip/rar, which agrees with + // the `cbz`/`cbr` extensions — both say `archive`. + 'application/vnd.comicbook+zip': 'archive', + 'application/vnd.comicbook-rar': 'archive', //torrent 'application/x-bittorrent': 'torrent', //subtitles 'text/vtt': 'subtitle', 'text/srt': 'subtitle', 'text/ssa': 'subtitle', + 'text/x-ssa': 'subtitle', 'text/sub': 'subtitle', -}; \ No newline at end of file + 'application/x-subrip': 'subtitle', +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index bdf9f59..4756c08 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -14,6 +14,7 @@ export * from './tools/StringArrayOperation.js'; export * from './tools/TwoStringsOperation.js'; export * from './config/AnimeKeywords.js'; +export * from './config/DomainMapping.js'; export * from './config/ExtentionsMapping.js'; export * from './config/KeywordsArray.js'; export * from './config/MimeTypeMapping.js';