niers-media 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "niers-media",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Canonical Inazuma Eleven media catalogue, source URLs, navigation and player contracts.",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./catalog": "./src/catalog.ts",
13
+ "./navigation": "./src/navigation.ts",
14
+ "./player": "./src/player.ts",
15
+ "./url": "./src/url.ts",
16
+ "./work": "./src/work.ts",
17
+ "./manga": "./src/manga.ts",
18
+ "./runtime": "./src/runtime.ts"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "bunx tsc --noEmit",
22
+ "test": "bun test"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "1.3.14",
26
+ "typescript": "5.9.3"
27
+ }
28
+ }
package/src/canon.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { GameFamily, GamePlatform, WorkRef } from "./work";
2
+ import { runtimeLinksForPlatforms } from "./runtime";
3
+
4
+ export interface GameRelease {
5
+ id: string;
6
+ work: WorkRef;
7
+ family: GameFamily;
8
+ platform: GamePlatform;
9
+ title: string;
10
+ acquisition: "official_purchase" | "owned_original" | "community_build";
11
+ sourceUrl?: string | null;
12
+ region?: string | null;
13
+ }
14
+
15
+ /** Measured catalogue anchors; missing releases remain explicit instead of invented. */
16
+ export const CANONICAL_GAME_FAMILIES: readonly WorkRef[] = [
17
+ { id: "inazuma_eleven_ds", kind: "game", title: "Inazuma Eleven", series: "inazuma_eleven", platforms: ["ds"], runtimeLinks: runtimeLinksForPlatforms(["ds"]) },
18
+ { id: "inazuma_eleven_2_ds", kind: "game", title: "Inazuma Eleven 2", series: "inazuma_eleven_2", platforms: ["ds"], runtimeLinks: runtimeLinksForPlatforms(["ds"]) },
19
+ { id: "inazuma_eleven_3_ds", kind: "game", title: "Inazuma Eleven 3", series: "inazuma_eleven_3", platforms: ["ds"], runtimeLinks: runtimeLinksForPlatforms(["ds"]) },
20
+ { id: "inazuma_eleven_go_3ds", kind: "game", title: "Inazuma Eleven GO", series: "inazuma_eleven_go", platforms: ["3ds"], runtimeLinks: runtimeLinksForPlatforms(["3ds"]) },
21
+ { id: "inazuma_eleven_go_chrono_stones_3ds", kind: "game", title: "Inazuma Eleven GO Chrono Stones", series: "inazuma_eleven_go_chrono_stones", platforms: ["3ds"], runtimeLinks: runtimeLinksForPlatforms(["3ds"]) },
22
+ { id: "inazuma_eleven_go_galaxy_3ds", kind: "game", title: "Inazuma Eleven GO Galaxy", series: "inazuma_eleven_go_galaxy", platforms: ["3ds"], runtimeLinks: runtimeLinksForPlatforms(["3ds"]) },
23
+ { id: "inazuma_eleven_strikers_wii", kind: "game", title: "Inazuma Eleven Strikers", series: "inazuma_eleven_strikers", platforms: ["wii"], runtimeLinks: runtimeLinksForPlatforms(["wii"]) },
24
+ { id: "inazuma_eleven_strikers_2012_wii", kind: "game", title: "Inazuma Eleven Strikers 2012 Extreme", series: "inazuma_eleven_strikers_2012", platforms: ["wii"], runtimeLinks: runtimeLinksForPlatforms(["wii"]) },
25
+ { id: "inazuma_eleven_strikers_2013_wii", kind: "game", title: "Inazuma Eleven GO Strikers 2013", series: "inazuma_eleven_strikers_2013", platforms: ["wii"], runtimeLinks: runtimeLinksForPlatforms(["wii"]) },
26
+ { id: "inazuma_eleven_victory_road", kind: "game", title: "Inazuma Eleven: Victory Road", series: "inazuma_eleven_victory_road", platforms: ["switch", "switch_2", "ps4", "ps5", "xbox", "pc"], runtimeLinks: runtimeLinksForPlatforms(["switch", "switch_2", "ps4", "ps5", "xbox", "pc"]) },
27
+ { id: "inazuma_eleven_cross", kind: "game", title: "Inazuma Eleven: Cross", series: "inazuma_eleven_cross", platforms: ["ios", "android", "mobile"], runtimeLinks: runtimeLinksForPlatforms(["ios", "android", "mobile"]) },
28
+ ];
29
+
30
+ /** Release/edition anchors. Patch numbers belong here only after a measured source is recorded. */
31
+ export const CANONICAL_GAME_RELEASES: readonly GameRelease[] = CANONICAL_GAME_FAMILIES.flatMap((work) =>
32
+ (work.platforms ?? []).map((platform) => ({
33
+ id: `${work.id}:${platform}`,
34
+ work,
35
+ family: work.series ?? "other",
36
+ platform,
37
+ title: work.title,
38
+ acquisition: "official_purchase" as const,
39
+ region: null,
40
+ })),
41
+ );
42
+
43
+ export function gamesForPlatform(platform: GamePlatform): WorkRef[] {
44
+ return CANONICAL_GAME_FAMILIES.filter((work) => work.platforms?.includes(platform));
45
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { MediaEntry } from "./work";
2
+ import { sortMediaEntries } from "./navigation";
3
+
4
+ export interface MediaQuery {
5
+ text?: string;
6
+ kind?: MediaEntry["kind"];
7
+ series?: string;
8
+ season?: number;
9
+ language?: string;
10
+ }
11
+
12
+ export function mergeMediaEntries(...catalogues: readonly (readonly MediaEntry[])[]): MediaEntry[] {
13
+ const merged = new Map<string, MediaEntry>();
14
+ for (const catalogue of catalogues) {
15
+ for (const entry of catalogue) {
16
+ const previous = merged.get(entry.id);
17
+ merged.set(entry.id, previous ? { ...previous, sources: [...previous.sources, ...entry.sources] } : entry);
18
+ }
19
+ }
20
+ return sortMediaEntries([...merged.values()].map((entry) => ({
21
+ ...entry,
22
+ sources: [...new Map(entry.sources.map((source) => [`${source.platform}:${source.id}`, source])).values()],
23
+ })));
24
+ }
25
+
26
+ export function queryMedia(entries: readonly MediaEntry[], query: MediaQuery = {}): MediaEntry[] {
27
+ const text = query.text?.trim().toLocaleLowerCase();
28
+ return sortMediaEntries(entries.filter((entry) => {
29
+ if (query.kind && entry.kind !== query.kind) return false;
30
+ if (query.series && entry.work.series !== query.series) return false;
31
+ if (query.season !== undefined && entry.season !== query.season) return false;
32
+ if (query.language && !entry.sources.some((source) => source.language === query.language)) return false;
33
+ if (text && !`${entry.title} ${entry.titleOriginal ?? ""} ${entry.work.title}`.toLocaleLowerCase().includes(text)) return false;
34
+ return true;
35
+ }));
36
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseOfficialCategories, parseOfficialEpisodes, parseYoutubeAtom } from "./inazuma-tv";
3
+
4
+ describe("Inazuma TV pure adapters", () => {
5
+ test("reads official JSON-LD categories and episode numbers from URLs", () => {
6
+ const html = `<script type="application/ld+json">${JSON.stringify({
7
+ "@type": "ItemList", name: "Categories", itemListElement: [{ position: 1, name: "Saison 1", url: "https://example.test/tv/saison1" }],
8
+ })}</script><script type="application/ld+json">${JSON.stringify({
9
+ "@type": "ItemList", name: "Episodes", itemListElement: [{ position: 1, name: "Épisode 12 - Le match", url: "https://example.test/ep-12" }],
10
+ })}</script>`;
11
+ expect(parseOfficialCategories(html)[0]?.slug).toBe("saison1");
12
+ expect(parseOfficialEpisodes(html)[0]?.number).toBe(12);
13
+ });
14
+
15
+ test("reads the finite Atom feed without scraping a rendered YouTube page", () => {
16
+ const xml = `<feed><title>Inazuma TV</title><entry><yt:videoId>abc12345678</yt:videoId><title>Episode 1</title><published>2026-01-01</published></entry></feed>`;
17
+ expect(parseYoutubeAtom(xml)[0]).toMatchObject({ videoId: "abc12345678", channel: "Inazuma TV" });
18
+ });
19
+ });
@@ -0,0 +1,121 @@
1
+ import type { MediaEntry, MediaSource, WorkRef } from "./work";
2
+ import { mediaId, workId } from "./work";
3
+ import { runtimeForMediaKind } from "./runtime";
4
+
5
+ export interface OfficialCategory {
6
+ position: number;
7
+ name: string;
8
+ url: string;
9
+ slug: string;
10
+ }
11
+
12
+ export interface OfficialEpisode {
13
+ number: number;
14
+ title: string;
15
+ url: string;
16
+ }
17
+
18
+ export interface AtomVideo {
19
+ videoId: string;
20
+ title: string;
21
+ url: string;
22
+ published: string | null;
23
+ channel: string | null;
24
+ }
25
+
26
+ export function slugFromUrl(url: string): string {
27
+ const path = url.split(/[?#]/, 1)[0]!.replace(/\/+$/, "");
28
+ return path.slice(path.lastIndexOf("/") + 1);
29
+ }
30
+
31
+ export function parseOfficialCategories(html: string): OfficialCategory[] {
32
+ return parseItemList(html, (name) => /categor/i.test(name))
33
+ .map((item) => ({ ...item, slug: slugFromUrl(item.url) }))
34
+ .filter((item) => item.slug.length > 0);
35
+ }
36
+
37
+ export function parseOfficialEpisodes(html: string): OfficialEpisode[] {
38
+ return parseItemList(html, (name) => /episode/i.test(name))
39
+ .map((item) => ({
40
+ number: Number.parseInt(/\/ep-(\d+)/i.exec(item.url)?.[1] ?? String(item.position), 10),
41
+ title: item.name.replace(/^\s*(?:episode|épisode)\s*\d+\s*[-–—:]\s*/i, "").trim(),
42
+ url: item.url,
43
+ }))
44
+ .filter((episode) => Number.isFinite(episode.number));
45
+ }
46
+
47
+ export function parseYoutubeAtom(xml: string): AtomVideo[] {
48
+ const beforeEntries = xml.split("<entry>", 1)[0] ?? "";
49
+ const channel = textTag(beforeEntries, "title");
50
+ const entries: AtomVideo[] = [];
51
+ for (const match of xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
52
+ const body = match[1]!;
53
+ const videoId = textTag(body, "yt:videoId");
54
+ const title = textTag(body, "title");
55
+ if (!videoId || !title) continue;
56
+ entries.push({ videoId, title, url: `https://www.youtube.com/watch?v=${videoId}`, published: textTag(body, "published"), channel });
57
+ }
58
+ return entries;
59
+ }
60
+
61
+ export function officialEpisodeEntry(
62
+ category: OfficialCategory,
63
+ episode: OfficialEpisode,
64
+ work: WorkRef = { id: workId("inazuma_eleven"), kind: "anime", title: "Inazuma Eleven", series: "inazuma_eleven" },
65
+ ): MediaEntry {
66
+ return {
67
+ id: mediaId("episode", `${category.slug}_${episode.number}`),
68
+ kind: "episode",
69
+ work,
70
+ title: episode.title,
71
+ season: category.position,
72
+ episode: episode.number,
73
+ runtime: runtimeForMediaKind("episode"),
74
+ sources: [{ platform: "official_page", id: episode.url, url: episode.url, official: true, verified: true }],
75
+ };
76
+ }
77
+
78
+ function parseItemList(html: string, accepts: (name: string) => boolean): { position: number; name: string; url: string }[] {
79
+ for (const object of jsonLdObjects(html)) {
80
+ if (object["@type"] !== "ItemList" || !accepts(typeof object.name === "string" ? object.name : "")) continue;
81
+ if (!Array.isArray(object.itemListElement)) continue;
82
+ return object.itemListElement.map((raw, index) => {
83
+ const item = raw as Record<string, unknown>;
84
+ return {
85
+ position: typeof item.position === "number" ? item.position : index + 1,
86
+ name: typeof item.name === "string" ? item.name.trim() : "",
87
+ url: typeof item.url === "string" ? item.url.trim() : "",
88
+ };
89
+ }).filter((item) => item.name.length > 0 && item.url.length > 0);
90
+ }
91
+ return [];
92
+ }
93
+
94
+ function jsonLdObjects(html: string): Record<string, unknown>[] {
95
+ const result: Record<string, unknown>[] = [];
96
+ for (const match of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
97
+ try {
98
+ const value: unknown = JSON.parse(match[1]!);
99
+ for (const root of Array.isArray(value) ? value : [value]) {
100
+ if (!root || typeof root !== "object") continue;
101
+ const object = root as Record<string, unknown>;
102
+ const graph = object["@graph"];
103
+ if (Array.isArray(graph)) result.push(...graph.filter((item): item is Record<string, unknown> => Boolean(item && typeof item === "object")));
104
+ else result.push(object);
105
+ }
106
+ } catch {
107
+ // One malformed JSON-LD block must not hide the other structured blocks.
108
+ }
109
+ }
110
+ return result;
111
+ }
112
+
113
+ function textTag(xml: string, name: string): string | null {
114
+ const match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(xml);
115
+ return match ? decodeXml(match[1]!) : null;
116
+ }
117
+
118
+ function decodeXml(value: string): string {
119
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
120
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#39;/g, "'").replace(/&amp;/g, "&");
121
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mergeMediaEntries, queryMedia } from "./catalog";
3
+ import { neighboringEntries } from "./navigation";
4
+ import { sourcePlayerUrl } from "./player";
5
+ import { mediaEntryPath } from "./url";
6
+ import type { MediaEntry } from "./work";
7
+
8
+ const episode: MediaEntry = {
9
+ id: "episode:inazuma_eleven_1_1",
10
+ kind: "episode",
11
+ work: { id: "inazuma_eleven", kind: "anime", title: "Inazuma Eleven", series: "inazuma_eleven" },
12
+ title: "Episode 1",
13
+ season: 1,
14
+ episode: 1,
15
+ sources: [{ platform: "youtube", id: "abc12345678", url: "https://youtu.be/abc12345678", official: true }],
16
+ };
17
+
18
+ describe("canonical media catalogue", () => {
19
+ test("merges sources without duplicating the entry", () => {
20
+ const merged = mergeMediaEntries([episode], [{ ...episode, sources: [{ ...episode.sources[0]!, id: "other1234567" }] }]);
21
+ expect(merged).toHaveLength(1);
22
+ expect(merged[0]!.sources).toHaveLength(2);
23
+ });
24
+
25
+ test("queries canonically and builds stable routes", () => {
26
+ expect(queryMedia([episode], { series: "inazuma_eleven" })[0]!.id).toBe(episode.id);
27
+ expect(mediaEntryPath(episode)).toBe("/episodes/episode%3Ainazuma_eleven_1_1");
28
+ });
29
+
30
+ test("keeps player URL policy in one owner", () => {
31
+ expect(sourcePlayerUrl(episode.sources[0]!, 12)).toContain("start=12");
32
+ expect(neighboringEntries([episode], episode.id).next).toBeNull();
33
+ });
34
+ });
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./catalog";
2
+ export * from "./canon";
3
+ export * from "./inazuma-tv";
4
+ export * from "./navigation";
5
+ export * from "./player";
6
+ export * from "./url";
7
+ export * from "./work";
8
+ export * from "./manga";
9
+ export * from "./runtime";
package/src/manga.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { MediaEntry, WorkRef } from "./work";
2
+ import { runtimeForMediaKind } from "./runtime";
3
+
4
+ export interface MangaVolume {
5
+ work: WorkRef;
6
+ volume: number;
7
+ title: string;
8
+ url?: string | null;
9
+ publisher?: string | null;
10
+ official?: boolean;
11
+ }
12
+
13
+ /** Adapts a manga volume to the same catalogue consumed by films and episodes. */
14
+ export function mangaVolumeEntry(volume: MangaVolume): MediaEntry {
15
+ return {
16
+ id: `${volume.work.id}:manga:${volume.volume}`,
17
+ kind: "manga_volume",
18
+ work: volume.work,
19
+ title: volume.title,
20
+ season: volume.volume,
21
+ volume: volume.volume,
22
+ runtime: runtimeForMediaKind("manga_volume"),
23
+ sources: volume.url
24
+ ? [{ id: `${volume.work.id}:manga:${volume.volume}:source`, platform: "manga_publisher", url: volume.url, official: volume.official ?? false }]
25
+ : [],
26
+ };
27
+ }
@@ -0,0 +1,41 @@
1
+ import type { MediaEntry } from "./work";
2
+
3
+ export function nextUnwatchedEpisode(available: readonly number[], watched: ReadonlySet<number>): number | null {
4
+ return [...available].sort((left, right) => left - right).find((episode) => !watched.has(episode)) ?? null;
5
+ }
6
+
7
+ export function neighboringEpisodes(available: readonly number[], episode: number): { previous: number | null; next: number | null } {
8
+ const sorted = [...available].sort((left, right) => left - right);
9
+ const index = sorted.indexOf(episode);
10
+ return index < 0
11
+ ? { previous: sorted.filter((candidate) => candidate < episode).at(-1) ?? null, next: sorted.find((candidate) => candidate > episode) ?? null }
12
+ : { previous: index > 0 ? sorted[index - 1]! : null, next: index + 1 < sorted.length ? sorted[index + 1]! : null };
13
+ }
14
+
15
+ export function nextUnwatchedEntry(
16
+ entries: readonly MediaEntry[],
17
+ watched: ReadonlySet<string>,
18
+ ): MediaEntry | null {
19
+ return entries.find((entry) => !watched.has(entry.id)) ?? null;
20
+ }
21
+
22
+ export function neighboringEntries(
23
+ entries: readonly MediaEntry[],
24
+ currentId: string,
25
+ ): { previous: MediaEntry | null; next: MediaEntry | null } {
26
+ const index = entries.findIndex((entry) => entry.id === currentId);
27
+ return index < 0
28
+ ? { previous: null, next: entries[0] ?? null }
29
+ : {
30
+ previous: index > 0 ? entries[index - 1]! : null,
31
+ next: index + 1 < entries.length ? entries[index + 1]! : null,
32
+ };
33
+ }
34
+
35
+ export function sortMediaEntries(entries: readonly MediaEntry[]): MediaEntry[] {
36
+ return [...entries].sort((left, right) => {
37
+ const leftSeason = left.season ?? Number.MAX_SAFE_INTEGER;
38
+ const rightSeason = right.season ?? Number.MAX_SAFE_INTEGER;
39
+ return leftSeason - rightSeason || (left.episode ?? left.volume ?? Number.MAX_SAFE_INTEGER) - (right.episode ?? right.volume ?? Number.MAX_SAFE_INTEGER) || left.id.localeCompare(right.id);
40
+ });
41
+ }
package/src/player.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { MediaSource } from "./work";
2
+
3
+ export type PlayerFormat = "mp4" | "webm" | "hls" | "dash" | "embed" | "page";
4
+
5
+ export function inferPlayerFormat(url: string): PlayerFormat {
6
+ const path = url.split(/[?#]/, 1)[0]!.toLowerCase();
7
+ if (path.endsWith(".m3u8") || path.endsWith(".m3u")) return "hls";
8
+ if (path.endsWith(".mpd")) return "dash";
9
+ if (path.endsWith(".webm")) return "webm";
10
+ if (path.endsWith(".mp4")) return "mp4";
11
+ return "embed";
12
+ }
13
+
14
+ export function sourcePlayerUrl(source: MediaSource, startSeconds = 0): string {
15
+ if (source.platform === "youtube") {
16
+ const params = new URLSearchParams({ rel: "0", modestbranding: "1" });
17
+ if (startSeconds > 0) params.set("start", String(Math.floor(startSeconds)));
18
+ return `https://www.youtube-nocookie.com/embed/${source.id}?${params}`;
19
+ }
20
+ if (source.platform === "dailymotion") {
21
+ const params = new URLSearchParams({ "queue-enable": "false", "sharing-enable": "false" });
22
+ if (startSeconds > 0) params.set("start", String(Math.floor(startSeconds)));
23
+ if (source.url.includes("/player/")) {
24
+ params.set("video", source.id);
25
+ return `${source.url.split("?")[0]}?${params}`;
26
+ }
27
+ return `https://www.dailymotion.com/embed/video/${source.id}?${params}`;
28
+ }
29
+ return source.url;
30
+ }
31
+
32
+ export function sourceFromEpisode(videoId: string, thumbnailUrl: string | null): MediaSource | null {
33
+ if (/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
34
+ return { platform: "youtube", id: videoId, url: `https://www.youtube.com/watch?v=${videoId}`, official: true };
35
+ }
36
+ const dailymotionId = thumbnailUrl?.match(/dailymotion\.com\/thumbnail\/video\/([A-Za-z0-9]+)/)?.[1];
37
+ if (dailymotionId) {
38
+ return { platform: "dailymotion", id: dailymotionId, url: thumbnailUrl!, official: true };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export function sourceIsPlayable(source: MediaSource): boolean {
44
+ return source.platform !== "official_page" && source.platform !== "manga_publisher";
45
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CANONICAL_GAME_FAMILIES } from "./canon";
3
+ import { RUST_RUNTIME_BY_MEDIA_KIND, RUST_RUNTIME_BY_PLATFORM, runtimeForPlatform } from "./runtime";
4
+
5
+ describe("Rust runtime coverage", () => {
6
+ test("every canonical game family has a closest Rust target for every platform", () => {
7
+ for (const work of CANONICAL_GAME_FAMILIES) {
8
+ expect(work.platforms?.length).toBeGreaterThan(0);
9
+ expect(work.runtimeLinks?.length).toBe(work.platforms?.length);
10
+ expect(work.runtimeLinks?.every((runtime) => runtime.sourcePath.startsWith("crates/"))).toBeTrue();
11
+ }
12
+ });
13
+
14
+ test("platform and media matrices are total and do not fake console emulation", () => {
15
+ expect(Object.keys(RUST_RUNTIME_BY_PLATFORM).length).toBe(15);
16
+ expect(Object.values(RUST_RUNTIME_BY_PLATFORM).some((runtime) => runtime.id === "ievr-steam-emulator")).toBeTrue();
17
+ expect(Object.values(RUST_RUNTIME_BY_PLATFORM).filter((runtime) => runtime.executesTarget)).toHaveLength(1);
18
+ expect(Object.keys(RUST_RUNTIME_BY_MEDIA_KIND)).toHaveLength(5);
19
+ expect(runtimeForPlatform("ds").status).toBe("closest_available");
20
+ });
21
+ });
package/src/runtime.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { CanonicalMediaKind, GamePlatform, RustRuntimeLink } from "./work";
2
+
3
+ const link = (
4
+ id: string,
5
+ role: RustRuntimeLink["role"],
6
+ crate: string,
7
+ sourcePath: string,
8
+ executesTarget: boolean,
9
+ status: RustRuntimeLink["status"],
10
+ ): RustRuntimeLink => ({ id, role, crate, sourcePath, executesTarget, status });
11
+
12
+ /**
13
+ * One measured Rust bridge per platform. There is no console emulator in this repository for
14
+ * DS/3DS/Wii/Switch/PlayStation/Xbox/mobile; those rows deliberately point at the closest reader
15
+ * and remain `closest_available` instead of claiming execution support.
16
+ */
17
+ export const RUST_RUNTIME_BY_PLATFORM: Readonly<Record<GamePlatform, RustRuntimeLink>> = {
18
+ ds: link("ds-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
19
+ "3ds": link("3ds-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
20
+ wii: link("wii-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
21
+ switch: link("switch-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
22
+ switch_2: link("switch-2-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
23
+ ps4: link("ps4-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
24
+ ps5: link("ps5-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
25
+ playstation: link("playstation-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
26
+ xbox: link("xbox-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
27
+ pc: link("ievr-steam-emulator", "emulator", "nie-steam", "crates/tools/nie-steam/src/emulator.rs", true, "available"),
28
+ ios: link("ios-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
29
+ android: link("android-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
30
+ mobile: link("mobile-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
31
+ arcade: link("arcade-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
32
+ unknown: link("unknown-format-reader", "format_reader", "nie-formats", "crates/engine/nie-formats/src", false, "closest_available"),
33
+ };
34
+
35
+ export const RUST_RUNTIME_BY_MEDIA_KIND: Readonly<Record<CanonicalMediaKind, RustRuntimeLink>> = {
36
+ episode: link("episode-catalog", "catalog", "nie-sql", "../iecode/src/niers/nie-sql/src", false, "closest_available"),
37
+ film: link("vfs-film-player", "media_player", "nie-explore", "crates/engine/nie-explore/src/cinema/browser_video.rs", true, "available"),
38
+ game_video: link("vfs-game-video-player", "media_player", "nie-explore", "crates/engine/nie-explore/src/native_video.rs", true, "available"),
39
+ manga_volume: link("manga-catalog", "catalog", "nie-site", "crates/tools/nie-site/src", false, "closest_available"),
40
+ special: link("special-media-catalog", "catalog", "nie-site", "crates/tools/nie-site/src", false, "closest_available"),
41
+ };
42
+
43
+ export function runtimeForPlatform(platform: GamePlatform): RustRuntimeLink {
44
+ return RUST_RUNTIME_BY_PLATFORM[platform];
45
+ }
46
+
47
+ export function runtimeForMediaKind(kind: CanonicalMediaKind): RustRuntimeLink {
48
+ return RUST_RUNTIME_BY_MEDIA_KIND[kind];
49
+ }
50
+
51
+ export function runtimeLinksForPlatforms(platforms: readonly GamePlatform[] | undefined): RustRuntimeLink[] {
52
+ return (platforms ?? ["unknown"]).map(runtimeForPlatform);
53
+ }
package/src/url.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { CanonicalMediaKind, GameFamily, MediaEntry } from "./work";
2
+
3
+ export type MediaRoute = "media" | "episodes" | "films" | "manga" | "games";
4
+
5
+ export function routeForKind(kind: CanonicalMediaKind): MediaRoute {
6
+ if (kind === "episode") return "episodes";
7
+ if (kind === "film" || kind === "game_video") return "films";
8
+ if (kind === "manga_volume") return "manga";
9
+ return "media";
10
+ }
11
+
12
+ export function mediaEntryPath(entry: Pick<MediaEntry, "id" | "kind">): string {
13
+ return `/${routeForKind(entry.kind)}/${encodeURIComponent(entry.id)}`;
14
+ }
15
+
16
+ export function gameFamilyPath(family: GameFamily): string {
17
+ return `/games/${family}`;
18
+ }
package/src/work.ts ADDED
@@ -0,0 +1,103 @@
1
+ /** Canonical work taxonomy shared by games, animation, films, manga and specials. */
2
+
3
+ export type WorkKind = "game" | "anime" | "film" | "manga" | "novel" | "special";
4
+
5
+ /** Platforms and hardware families, kept separate from an individual release. */
6
+ export type GamePlatform =
7
+ | "ds"
8
+ | "3ds"
9
+ | "wii"
10
+ | "switch"
11
+ | "switch_2"
12
+ | "ps4"
13
+ | "ps5"
14
+ | "playstation"
15
+ | "xbox"
16
+ | "pc"
17
+ | "ios"
18
+ | "android"
19
+ | "mobile"
20
+ | "arcade"
21
+ | "unknown";
22
+
23
+ /** Canonical game families supported by the catalogue. */
24
+ export type GameFamily =
25
+ | "inazuma_eleven"
26
+ | "inazuma_eleven_2"
27
+ | "inazuma_eleven_3"
28
+ | "inazuma_eleven_strikers"
29
+ | "inazuma_eleven_strikers_2012"
30
+ | "inazuma_eleven_strikers_2013"
31
+ | "inazuma_eleven_go"
32
+ | "inazuma_eleven_go_chrono_stones"
33
+ | "inazuma_eleven_go_galaxy"
34
+ | "inazuma_eleven_ares"
35
+ | "inazuma_eleven_orion"
36
+ | "inazuma_eleven_victory_road"
37
+ | "inazuma_eleven_cross"
38
+ | "other";
39
+
40
+ export type CanonicalMediaKind = "episode" | "film" | "game_video" | "manga_volume" | "special";
41
+
42
+ export type RustRuntimeRole = "emulator" | "game_runtime" | "format_reader" | "media_player" | "catalog";
43
+
44
+ export interface RustRuntimeLink {
45
+ /** Stable identifier for the Rust capability closest to this content. */
46
+ id: string;
47
+ role: RustRuntimeRole;
48
+ crate: string;
49
+ sourcePath: string;
50
+ /** True only when the Rust owner actually executes the target, not merely reads it. */
51
+ executesTarget: boolean;
52
+ status: "available" | "closest_available" | "unimplemented";
53
+ }
54
+
55
+ export interface WorkRef {
56
+ /** Stable slug, never a translated display label. */
57
+ id: string;
58
+ kind: WorkKind;
59
+ title: string;
60
+ /** Optional official Japanese title or source title. */
61
+ titleOriginal?: string | null;
62
+ series?: GameFamily | null;
63
+ platforms?: readonly GamePlatform[];
64
+ runtimeLinks?: readonly RustRuntimeLink[];
65
+ order?: number | null;
66
+ }
67
+
68
+ export interface MediaSource {
69
+ platform: "vfs" | "youtube" | "dailymotion" | "official_page" | "manga_publisher" | "unknown";
70
+ /** Stable source identifier, not a display URL. */
71
+ id: string;
72
+ url: string;
73
+ language?: string | null;
74
+ official: boolean;
75
+ verified?: boolean;
76
+ }
77
+
78
+ export interface MediaEntry {
79
+ /** Stable deduplication key across every source and host. */
80
+ id: string;
81
+ kind: CanonicalMediaKind;
82
+ work: WorkRef;
83
+ title: string;
84
+ titleOriginal?: string | null;
85
+ season?: number | null;
86
+ episode?: number | null;
87
+ volume?: number | null;
88
+ chapter?: number | null;
89
+ date?: string | null;
90
+ durationSeconds?: number | null;
91
+ thumbnailUrl?: string | null;
92
+ runtime?: RustRuntimeLink;
93
+ sources: readonly MediaSource[];
94
+ }
95
+
96
+ export function workId(value: string): string {
97
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
98
+ }
99
+
100
+ export function mediaId(kind: CanonicalMediaKind, work: string, ordinal?: number | null): string {
101
+ const suffix = ordinal == null ? "" : `_${ordinal}`;
102
+ return `${kind}:${workId(work)}${suffix}`;
103
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.niers-base.json",
3
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
4
+ }