Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,24 @@ Tokens land at `~/.config/epic-fab/auth.json` (mode `600`, never in URLs, never
| `epic-fab list` | JSON dump of every owned Fab asset (id, title, type, ownedAt) |
| `epic-fab download <id> --into <dir>` | Download a single asset to a target directory |
| `epic-fab sync --project <path>` | Bulk-download library into a UE project's `Content/Fab/` tree |
| `epic-fab tui` | Browse and filter interactively; `Enter` downloads and `Ctrl+O` opens Fab |
| `epic-fab logout` | Delete persisted auth tokens |

Every command exits with a meaningful code (`0` OK, `1` user error, `2` not authenticated, `3` network error) and emits JSON on stdout for piping.
Every command exits with a meaningful code (`0` OK, `1` user error, `2` not authenticated, `3` network error). The non-interactive result commands emit JSON on stdout for piping; `tui` is interactive.

### Interactive TUI

```bash
epic-fab tui --lang zh-CN
```

The TUI filters as you type. Use `↑`/`↓` to select, `Enter` to download, `Ctrl+O`
to open the original Fab listing, `Ctrl+I` to open the selected asset's thumbnail,
and `Esc` to quit.
All TUI text lives in [`src/locales/en.json`](./src/locales/en.json) and
[`src/locales/zh-CN.json`](./src/locales/zh-CN.json). Add any
`src/locales/<locale>.json` file (for example, `fr-FR.json`) and use
`epic-fab tui --lang <locale>`; a missing or invalid file falls back to English.

---

Expand Down
28 changes: 28 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from "./api.ts";
import { downloadAsset } from "./download.ts";
import { createInterface } from "node:readline/promises";
import { runTui, safeTerminalText, TuiUserError } from "./tui.ts";
import { loadTuiTranslator } from "./tuiI18n.ts";

const USAGE = `epic-fab — Epic Games / Fab.com asset library on Linux

Expand All @@ -30,6 +32,8 @@ Commands:
list [--json] List owned Fab assets
download <asset-id> [...] Download asset(s) to disk
sync --project <path> Bulk-download library into a UE project's Content/
tui [--lang <locale>]
Browse, filter, and download assets interactively
whoami Show current authenticated Epic account
logout Delete persisted auth tokens

Expand All @@ -41,6 +45,7 @@ Options:
--project <path> sync: UE project root
--concurrency <n> CDN chunk fetch concurrency (default 8)
--no-skip Redownload even when on-disk SHA1 matches
--lang <locale> tui: locale-file name (default: $LANG, fallback en)
`;

const VERSION = "0.1.0";
Expand Down Expand Up @@ -350,6 +355,27 @@ async function cmdSync(argv: ReadonlyArray<string>, engineVersion: string): Prom
}
}

async function cmdTui(argv: ReadonlyArray<string>, engineVersion: string): Promise<number> {
const translate = await loadTuiTranslator(findFlagValue(argv, "--lang") ?? process.env["LANG"]);
const tokens = await loadAndRefresh();
if (!tokens) {
console.error(translate("not_authenticated"));
return EXIT_NOT_AUTHENTICATED;
}

try {
const result = await runTui(tokens, {
engineVersion,
locale: findFlagValue(argv, "--lang"),
});
return result.downloadFailed ? EXIT_NETWORK_ERROR : EXIT_OK;
} catch (err) {
const error = safeTerminalText(err instanceof Error ? err.message : String(err));
console.error(translate("tui_failed", { error }));
return err instanceof TuiUserError ? EXIT_USER_ERROR : EXIT_NETWORK_ERROR;
}
}

async function cmdWhoami(): Promise<number> {
const tokens = await loadTokens();
if (!tokens) {
Expand Down Expand Up @@ -396,6 +422,8 @@ async function main(argv: string[]): Promise<number> {
return cmdDownload(rest, engineVersion);
case "sync":
return cmdSync(rest, engineVersion);
case "tui":
return cmdTui(rest, engineVersion);
case "whoami":
return cmdWhoami();
case "logout":
Expand Down
32 changes: 19 additions & 13 deletions src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { chunkPath, decodeChunkPayload, type ChunkInfo, type ChunkPart } from ".
export interface DownloadOptions {
targetDir: string;
preserveStructure: boolean;
/** Suppress the built-in stderr progress lines for an interactive caller. */
quiet?: boolean;
/** Max concurrent CDN chunk fetches. Default 8; Epic CDNs often rate-limit around 16+. */
concurrency?: number;
/** Skip files whose on-disk SHA1 already matches the manifest. Default true. */
Expand Down Expand Up @@ -368,7 +370,7 @@ export async function downloadAsset(
};
const toDownload: PlannedFile[] = [];

if (skipExisting) {
if (skipExisting && !opts.quiet) {
process.stderr.write(`Checking ${fileTotal} on-disk file(s) for SHA1 match…\n`);
}

Expand All @@ -383,15 +385,15 @@ export async function downloadAsset(
bytesTotal += file.size;
filesDone += 1;
skipped += 1;
process.stderr.write(`[${filesDone}/${fileTotal}] skip ${relativePath}\n`);
if (!opts.quiet) process.stderr.write(`[${filesDone}/${fileTotal}] skip ${relativePath}\n`);
continue;
}

toDownload.push({ file, relativePath, targetPath });
}

if (toDownload.length === 0) {
process.stderr.write(`Nothing to fetch — all ${skipped} file(s) already match manifest SHA1\n`);
if (!opts.quiet) process.stderr.write(`Nothing to fetch — all ${skipped} file(s) already match manifest SHA1\n`);
return { files: writtenFiles, bytesTotal, skipped };
}

Expand All @@ -410,18 +412,20 @@ export async function downloadAsset(
remainingUses,
);

process.stderr.write(
`Downloading ${toDownload.length}/${fileTotal} file(s), ${uniqueNeeded} unique chunk(s)` +
` (concurrency ${concurrency}, prefetch window ${prefetchWindow}` +
(bases.length > 1 ? `, ${bases.length} CDN bases` : "") +
")…\n",
);
if (!opts.quiet) {
process.stderr.write(
`Downloading ${toDownload.length}/${fileTotal} file(s), ${uniqueNeeded} unique chunk(s)` +
` (concurrency ${concurrency}, prefetch window ${prefetchWindow}` +
(bases.length > 1 ? `, ${bases.length} CDN bases` : "") +
")…\n",
);
}

// Kick the first window so the pool is busy before assembly starts.
primeWindow(cache, order, 0, prefetchWindow);

for (const { file, relativePath, targetPath } of toDownload) {
process.stderr.write(`[${filesDone + 1}/${fileTotal}] downloading ${relativePath}…\n`);
if (!opts.quiet) process.stderr.write(`[${filesDone + 1}/${fileTotal}] downloading ${relativePath}…\n`);
const bytes = await assembleFile(
file,
cache,
Expand All @@ -433,9 +437,11 @@ export async function downloadAsset(
writtenFiles.push(targetPath);
bytesTotal += bytes;
filesDone += 1;
process.stderr.write(
`[${filesDone}/${fileTotal}] wrote ${relativePath} (${bytes} bytes, cache ${cache.cachedCount()} chunks)\n`,
);
if (!opts.quiet) {
process.stderr.write(
`[${filesDone}/${fileTotal}] wrote ${relativePath} (${bytes} bytes, cache ${cache.cachedCount()} chunks)\n`,
);
}
}

return { files: writtenFiles, bytesTotal, skipped };
Expand Down
33 changes: 33 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"interactive_terminal_required": "tui requires an interactive terminal",
"not_authenticated": "Not authenticated. Run: epic-fab auth",
"tui_failed": "tui failed: {error}",
"unknown": "unknown",
"fetching_library": "Fetching library…",
"search_status": "epic-fab tui {matches}/{total} assets Search: {query}",
"help": "Type to search · ↑/↓ select · Enter download · Ctrl+O open Fab · Ctrl+I open thumbnail · Esc quit",
"detail_type": "Type: {type}",
"detail_id": "ID: {id}",
"detail_fab": "Fab: {url}",
"thumbnail_link": "Thumbnail: {url}",
"detail_download": "Download: {path}",
"opening_fab": "Opening the Fab listing…",
"opened_fab": "Opened the Fab listing in your default browser.",
"open_fab_failed": "Could not open the Fab listing; copy the URL from the details pane.",
"thumbnail_unavailable": "Thumbnail unavailable for this asset.",
"opening_thumbnail": "Opening the thumbnail…",
"opened_thumbnail": "Opened the thumbnail in your default browser.",
"open_thumbnail_failed": "Could not open the thumbnail.",
"engine_fallback": "Engine {requested} is not available for \"{title}\". Using {selected} (highest compatible version).",
"engine_higher": "Engine {requested} is not available for \"{title}\".",
"engine_available": "Available versions: {versions}",
"engine_prompt": "Enter a version from the list above (or 'q' to abort): ",
"engine_invalid": "\"{value}\" is not in the available versions.",
"engine_none": "No engine versions found for \"{title}\".",
"download_cancelled": "Download cancelled.",
"download_in_progress": "A download is in progress. Wait for it to finish before quitting.",
"download_already_in_progress": "A download is already in progress.",
"downloading": "Downloading {title}…",
"downloaded": "Downloaded to {path}",
"download_failed": "Download failed: {error}"
}
33 changes: 33 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"interactive_terminal_required": "tui 需要在交互式终端中运行",
"not_authenticated": "尚未认证,请先运行:epic-fab auth",
"tui_failed": "tui 运行失败:{error}",
"unknown": "未知",
"fetching_library": "正在获取资源库…",
"search_status": "epic-fab tui {matches}/{total} 个资源 搜索:{query}",
"help": "直接输入搜索 · ↑/↓ 选择 · Enter 下载 · Ctrl+O 打开 Fab · Ctrl+I 打开缩略图 · Esc 退出",
"detail_type": "类型:{type}",
"detail_id": "ID:{id}",
"detail_fab": "Fab:{url}",
"thumbnail_link": "缩略图:{url}",
"detail_download": "下载位置:{path}",
"opening_fab": "正在打开 Fab 页面…",
"opened_fab": "已在默认浏览器中打开 Fab 页面。",
"open_fab_failed": "无法打开 Fab 页面;请从详情栏复制链接。",
"thumbnail_unavailable": "此资源没有可用缩略图。",
"opening_thumbnail": "正在打开缩略图…",
"opened_thumbnail": "已在默认浏览器中打开缩略图。",
"open_thumbnail_failed": "无法打开缩略图。",
"engine_fallback": "\"{title}\" 不支持引擎 {requested},将使用最高兼容版本 {selected}。",
"engine_higher": "\"{title}\" 不支持引擎 {requested}。",
"engine_available": "可用版本:{versions}",
"engine_prompt": "请输入以上列表中的版本(或输入 q 取消):",
"engine_invalid": "\"{value}\" 不在可用版本中。",
"engine_none": "\"{title}\" 没有可用的引擎版本。",
"download_cancelled": "已取消下载。",
"download_in_progress": "下载正在进行,请等待完成后再退出。",
"download_already_in_progress": "已有下载任务正在进行。",
"downloading": "正在下载 {title}…",
"downloaded": "已下载到 {path}",
"download_failed": "下载失败:{error}"
}
Loading