├── icon.jpg ├── .gitignore ├── screenshot.jpg ├── src ├── mocks │ └── index.ts ├── utils │ ├── progress-bar.ts │ └── notification.ts ├── loading.ts ├── config.ts ├── download-manager.ts ├── services │ └── archive.service.ts ├── screen.ts ├── input.ts ├── fetch.ts ├── menu.ts └── main.ts ├── tsconfig.json ├── package.json └── README.md /icon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mklan/nx-archive-browser/HEAD/icon.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.nro 2 | /node_modules 3 | /romfs/main.js 4 | /romfs/main.js.map 5 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mklan/nx-archive-browser/HEAD/screenshot.jpg -------------------------------------------------------------------------------- /src/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export function getTitles(x: number) { 2 | return Array.from(Array(x)).map((_, i) => ({ 3 | fileName: `title${i}.zip`, 4 | title: `title${i}`, 5 | date: "none", 6 | size: 20, 7 | })); 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "moduleResolution": "node", 5 | "noEmit": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "types": [ 10 | "nxjs-runtime" 11 | ] 12 | }, 13 | "include": [ 14 | "src/**/*.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/progress-bar.ts: -------------------------------------------------------------------------------- 1 | export function progressBar(value: number, size: number = 20) { 2 | const fill = Math.floor(size * value); 3 | 4 | const filled = Array.from(Array(fill)).reduce((acc) => (acc += "#"), ""); 5 | const empty = Array.from(Array(size - fill)).reduce( 6 | (acc) => (acc += "_"), 7 | "" 8 | ); 9 | 10 | return `[${filled}${empty}]`; 11 | } 12 | -------------------------------------------------------------------------------- /src/loading.ts: -------------------------------------------------------------------------------- 1 | import { createScreen } from "./screen"; 2 | 3 | export const loading = (() => { 4 | let msg = "loading..."; 5 | let isLoading = false; 6 | 7 | const screen = createScreen(80, 22); 8 | 9 | return { 10 | start: (text: string) => { 11 | msg = text; 12 | isLoading = true; 13 | }, 14 | stop: () => (isLoading = false), 15 | render: () => { 16 | console.log(screen.centerTextVert(msg)); 17 | }, 18 | isLoading: () => isLoading, 19 | }; 20 | })(); 21 | -------------------------------------------------------------------------------- /src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | const createNotification = () => { 2 | let message: string[] = []; 3 | let timeoutId = 0; 4 | 5 | function show(time: number, ...msg: string[]) { 6 | message = msg; 7 | 8 | clearTimeout(timeoutId); 9 | timeoutId = setTimeout(() => { 10 | message = []; 11 | }, time); 12 | } 13 | 14 | function toString() { 15 | return message.length ? message.join(", ") : ""; 16 | } 17 | 18 | return { 19 | show, 20 | toString, 21 | }; 22 | }; 23 | 24 | export const notification = createNotification(); 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "titleId": "01ee9aec91da0000", 3 | "name": "nx-archive-browser", 4 | "version": "0.1.3", 5 | "private": true, 6 | "description": "", 7 | "author": { 8 | "name": "mklan" 9 | }, 10 | "scripts": { 11 | "build": "esbuild --bundle --sourcemap --sources-content=false --target=es2022 --format=esm src/main.ts --outfile=romfs/main.js", 12 | "nro": "nxjs-pack", 13 | "copy": "curl -T nx-archive-browser.nro ftp://192.168.1.46:5000/switch/" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "esbuild": "^0.17.19", 18 | "nxjs-pack": "^0.0.32", 19 | "nxjs-runtime": "^0.0.44" 20 | }, 21 | "dependencies": { 22 | "kleur": "^4.1.5", 23 | "linkedom": "^0.16.6", 24 | "nxjs-constants": "^0.0.27", 25 | "sisteransi": "^1.0.5" 26 | } 27 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const defaultConfig = (path: string) => ({ 2 | folder: "archives", 3 | collections: { 4 | add: "empty", 5 | collections: "empty", 6 | to: "empty", 7 | [path]: "empty", 8 | }, 9 | }); 10 | 11 | function loadConfig(path: string) { 12 | let config; 13 | 14 | async function load() { 15 | try { 16 | const buffer = await Switch.readFile(path); 17 | 18 | return JSON.parse(new TextDecoder().decode(buffer)); 19 | } catch (e) { 20 | const config = defaultConfig(path); 21 | Switch.writeFileSync(path, JSON.stringify(config, null, 2)); 22 | return config; 23 | } 24 | } 25 | 26 | async function get(key: string) { 27 | if (config) { 28 | return config[key]; 29 | } 30 | config = await load(); 31 | return config[key]; 32 | } 33 | 34 | return { get }; 35 | } 36 | 37 | export const config = loadConfig("sdmc:/config/nx-archive-browser/config.json"); 38 | -------------------------------------------------------------------------------- /src/download-manager.ts: -------------------------------------------------------------------------------- 1 | import { createMenu } from "./menu"; 2 | import { progressBar } from "./utils/progress-bar"; 3 | 4 | type Download = { 5 | customState?: string; 6 | fileName: string; 7 | progress: number; 8 | speed: number; 9 | }; 10 | 11 | export function createDownloadManager() { 12 | let downloads: Download[] = []; 13 | 14 | const menu = createMenu({ 15 | id: "downloads", 16 | items: [], 17 | height: 5, 18 | onSelect: () => {}, 19 | }); 20 | 21 | function add(fileName: string, customState: string = "") { 22 | downloads = [ 23 | { customState, fileName, progress: 0, speed: 0 }, 24 | ...downloads, 25 | ]; 26 | } 27 | 28 | function update(fileName: string, progress: number, speed: number) { 29 | const index = downloads.findIndex( 30 | (download) => download.fileName === fileName 31 | ); 32 | if (index < 0) return; 33 | 34 | downloads[index].progress = progress; 35 | downloads[index].speed = speed; 36 | } 37 | 38 | function render() { 39 | const items = downloads.map((download) => { 40 | const isComplete = download.progress >= 1; 41 | let status = isComplete 42 | ? "complete" 43 | : `${progressBar(download.progress)} ${Math.floor( 44 | download.progress * 100 45 | )}% ${download.speed?.toFixed(2) || "?"} Mb/s `; 46 | 47 | return { 48 | meta: {}, 49 | title: `${strWidth(download.fileName, 36)} ${ 50 | download.customState || status 51 | }`, 52 | }; 53 | }); 54 | 55 | menu.setItems(items); 56 | menu.render(false); 57 | } 58 | 59 | return { render, add, update }; 60 | } 61 | 62 | function strWidth(str: string, size: number) { 63 | if (str.length >= size) { 64 | return str.slice(0, size); 65 | } 66 | const space = Array.from(Array(size - str.length)) 67 | .fill(" ") 68 | .join(""); 69 | return `${str}${space}`; 70 | } 71 | -------------------------------------------------------------------------------- /src/services/archive.service.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from "linkedom"; 2 | import { getTitles } from "../mocks"; 3 | import { fetchProgress } from "../fetch"; 4 | 5 | type Collection = { 6 | title: string; 7 | archiveName: string; 8 | }; 9 | 10 | const mock = false; 11 | 12 | export async function fetchArchiveList(archiveName: string) { 13 | if (mock) { 14 | return getTitles(50); 15 | } 16 | 17 | return fetch(`https://archive.org/download/${archiveName}`) 18 | .then((res) => { 19 | if (!res.ok) { 20 | throw new Error("not found"); 21 | } 22 | return res.text(); 23 | }) 24 | .then((data) => { 25 | const dom = parseHTML(data); 26 | 27 | const rows = dom.document.querySelectorAll( 28 | ".directory-listing-table tbody tr" 29 | ); 30 | 31 | const titles = rows 32 | .filter((_, i) => i > 0) 33 | .map((row) => { 34 | const cells = row.querySelectorAll("td"); 35 | const fileName = cells[0].querySelector("a").href; 36 | return [ 37 | fileName, 38 | ...cells.map((cell) => 39 | cell.textContent.replace("(View Contents)", "") 40 | ), 41 | ]; 42 | }) 43 | .map((title) => ({ 44 | fileName: title[0], 45 | title: title[1], 46 | date: title[2], 47 | size: title[3], 48 | })); 49 | 50 | return titles; 51 | }); 52 | } 53 | 54 | export async function download( 55 | collection: Collection, 56 | fileName: string, 57 | folder: string, 58 | { onDownloadStart, onProgress } 59 | ) { 60 | const url = `https://archive.org/download/${collection.archiveName}/${fileName}`; 61 | 62 | const blob = await fetchProgress(url, { 63 | onDownloadStart, 64 | onProgress, 65 | }).then((res) => res.blob()); 66 | 67 | const buffer = await new Response(blob).arrayBuffer(); 68 | Switch.mkdirSync(`sdmc:/${folder}`); 69 | Switch.writeFileSync(`sdmc:/${folder}/${fileName}`, buffer); 70 | } 71 | -------------------------------------------------------------------------------- /src/screen.ts: -------------------------------------------------------------------------------- 1 | export function createScreen(width: number = 80, height: number = 44) { 2 | const clearScreen = [...Array(height).fill("")].join("\n"); 3 | 4 | function pad(str: string, amount: number) { 5 | return space(amount) + str; 6 | } 7 | 8 | function space(amount: number) { 9 | return Array.from(Array(amount)).fill(" ").join(""); 10 | } 11 | 12 | function centerText(str: string) { 13 | const amount = Math.floor((width - str.length) / 2); 14 | return pad(str, amount); 15 | } 16 | 17 | type SpreadPart = { 18 | text: string; 19 | color: (input: string) => string; 20 | }; 21 | 22 | type SpreadOpts = { 23 | left: SpreadPart; 24 | center: SpreadPart; 25 | right: SpreadPart; 26 | }; 27 | 28 | function spread2({ left, right }: Omit) { 29 | const amount = width - left.text.length - right.text.length; 30 | 31 | return `${left.color(left.text)}${space(amount)}${right.color(right.text)}`; 32 | } 33 | 34 | function spread3({ left, center, right }: spreadOpts) { 35 | const spaceToCenter = 36 | Math.floor((width - center.text.length) / 2) - left.text.length; 37 | 38 | const leftPart = left.text; 39 | const centerPart = pad(center.text, spaceToCenter); 40 | 41 | const spaceFromCenterPart = 42 | width - leftPart.length - centerPart.length - right.text.length; 43 | const rightPart = pad(right.text, spaceFromCenterPart); 44 | 45 | return `${left.color(leftPart)}${center.color(centerPart)}${right.color( 46 | rightPart 47 | )}`; 48 | } 49 | 50 | function right(str: string, padding: number = 0) { 51 | const padLeft = width - str.length - padding; 52 | return pad(str, padLeft); 53 | } 54 | 55 | /** @remark currently only one line */ 56 | function centerTextVert(str: string) { 57 | const text = centerText(str); 58 | const padding = [...Array(Math.floor(height / 2)).fill("")].join("\n"); 59 | return `${padding} 60 | ${text} 61 | ${padding}`; 62 | } 63 | 64 | function clear() { 65 | console.log(clearScreen); 66 | } 67 | 68 | return { centerText, centerTextVert, clear, right, spread2, spread3 }; 69 | } 70 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | // import readline from 'readline'; 2 | 3 | import { Hid } from "nxjs-constants"; 4 | 5 | const { Button } = Hid; 6 | 7 | const readline = {}; 8 | 9 | export function input({ onButtonDown, onButtonUp }, isNodeJS?: boolean) { 10 | if (isNodeJS) { 11 | nodeJSinput({ onButtonDown, onButtonUp }); 12 | return; 13 | } 14 | 15 | addEventListener("buttondown", (e) => { 16 | if (e.detail === Button.ZL) { 17 | onButtonDown("ZL"); 18 | } 19 | if (e.detail === Button.ZR) { 20 | onButtonDown("ZR"); 21 | } 22 | if (e.detail === Button.L) { 23 | onButtonDown("L"); 24 | } 25 | if (e.detail === Button.R) { 26 | onButtonDown("R"); 27 | } 28 | if ( 29 | [Button.Down, Button.StickLDown, Button.StickRDown].includes(e.detail) 30 | ) { 31 | onButtonDown("down"); 32 | } 33 | if ([Button.Up, Button.StickLUp, Button.StickRUp].includes(e.detail)) { 34 | onButtonDown("up"); 35 | } 36 | if ( 37 | [Button.Left, Button.StickLLeft, Button.StickRLeft].includes(e.detail) 38 | ) { 39 | onButtonDown("left"); 40 | } 41 | if ( 42 | [Button.Right, Button.StickLRight, Button.StickRRight].includes(e.detail) 43 | ) { 44 | onButtonDown("right"); 45 | } 46 | if ([Button.A].includes(e.detail)) { 47 | onButtonDown("A"); 48 | } 49 | if ([Button.B].includes(e.detail)) { 50 | onButtonDown("B"); 51 | } 52 | if ([Button.Y].includes(e.detail)) { 53 | onButtonDown("Y"); 54 | } 55 | if ([Button.X].includes(e.detail)) { 56 | onButtonDown("X"); 57 | } 58 | }); 59 | 60 | addEventListener("buttonup", (e) => { 61 | if ( 62 | [Button.Down, Button.StickLDown, Button.StickRDown].includes(e.detail) 63 | ) { 64 | onButtonUp("down"); 65 | } 66 | if ([Button.Up, Button.StickLUp, Button.StickRUp].includes(e.detail)) { 67 | onButtonUp("up"); 68 | } 69 | }); 70 | } 71 | 72 | function nodeJSinput({ onButtonDown, onButtonUp }) { 73 | readline.emitKeypressEvents(process.stdin); 74 | 75 | process.stdin.on("keypress", (ch, { name, ctrl }) => { 76 | if (name === "up") onButtonDown("up"); 77 | if (name === "down") onButtonDown("down"); 78 | if (name === "a") onButtonDown("A"); 79 | if (ctrl && name === "c") process.exit(1); 80 | }); 81 | 82 | process.stdin.setRawMode(true); 83 | process.stdin.resume(); 84 | } 85 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | type Stats = { 2 | progress: number; 3 | receivedLength: number; 4 | contentLength: number; 5 | speed: number; 6 | }; 7 | 8 | type FetchOptions = { 9 | onProgress: (stats: Stats) => void; 10 | onDownloadStart: () => void; 11 | }; 12 | 13 | export async function fetchProgress( 14 | url: string, 15 | { onProgress, onDownloadStart }: FetchOptions 16 | ) { 17 | // Step 1: start the fetch and obtain a reader 18 | const response = await fetch(url); 19 | 20 | if (!response.ok) { 21 | throw new Error(response.status.toString()); 22 | } 23 | 24 | const reader = response.body!.getReader(); 25 | 26 | // Step 2: get total length 27 | const contentLength = +response.headers.get("Content-Length")!; 28 | 29 | // Step 3: read the data 30 | let receivedLength = 0; // received that many bytes at the moment 31 | 32 | let last = { time: 0, value: 0 }; 33 | let counter = 0; 34 | let speed = 0; 35 | 36 | const stream = new ReadableStream({ 37 | start(controller) { 38 | onDownloadStart(); 39 | return pump(); 40 | function pump(): Promise< 41 | ReadableStreamReadResult | undefined 42 | > { 43 | return reader.read().then(({ done, value }) => { 44 | // When no more data needs to be consumed, close the stream 45 | if (done) { 46 | controller.close(); 47 | return; 48 | } 49 | // Enqueue the next data chunk into our target stream 50 | controller.enqueue(value); 51 | 52 | receivedLength += value.length; 53 | 54 | const progress = receivedLength / (contentLength / 100) / 100; 55 | 56 | const current = { time: Date.now(), value: receivedLength }; 57 | 58 | if (counter % 50 === 0) { 59 | if (last.time) { 60 | const time = current.time - last.time; 61 | const val = current.value - last.value; 62 | 63 | speed = byteToMB(val / (time / 1000)); 64 | } 65 | 66 | last = { ...current }; 67 | } 68 | 69 | onProgress({ 70 | progress: isFinite(progress) ? progress : 0, 71 | receivedLength, 72 | contentLength, 73 | speed, 74 | }); 75 | 76 | counter += 1; 77 | return pump(); 78 | }); 79 | } 80 | }, 81 | }); 82 | 83 | return new Response(stream); 84 | } 85 | 86 | function byteToMB(value: number) { 87 | return value / 1024 / 1024; 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nx-archive-browser 2 | 3 | Browse and download archives from archive.org on your Nintendo Switch 4 | 5 | 6 | 7 | 8 | ## Install 9 | 10 | 1. Copy `nx-archive-browser.nro` to `/switch`. The app will appear on the Homebrew Menu. 11 | 12 | 2. Configure your archive collections and root download folder in `config/nx-archive-browser/config.json`. 13 | The keys represent collection folders inside your download folder. The values are archive.org identifier of collections. 14 | 15 | Example: 16 | 17 | ```Json 18 | { 19 | "folder": "roms", 20 | "collections": { 21 | "N64": "SomeCollectionByGhostw***", 22 | "SNES": "SomeOtherCollectionByGhostw***" 23 | } 24 | } 25 | ``` 26 | 27 | The archives will be downloaded to `sdmc:/roms/N64` and `sdmc:/roms/SNES`. 28 | 29 | Read the [legal terms](https://archive.org/about/terms.php) of archive.org. I would encourage you to only download licence free archives, archives you developed on your own or in some cases own a copy of the original product (depends where you are located). 30 | 31 | ## Credits 32 | 33 | [TooTallNate - nxjs](https://github.com/TooTallNate/nx.js) - JS runtime for the Switch 34 | 35 | 36 | ## Possible TODOs 37 | 38 | - [ ] cancel downloads 39 | - [ ] search 40 | - [ ] external meta-lists [top, popular] 41 | - [ ] metadata [in-game screenshot, description] 42 | - [ ] unzip 43 | 44 | ## LICENSE 45 | 46 | MIT License 47 | 48 | Copyright (c) 2021 - 2024 Matthias Klan 49 | 50 | Permission is hereby granted, free of charge, to any person obtaining a copy 51 | of this software and associated documentation files (the "Software"), to deal 52 | in the Software without restriction, including without limitation the rights 53 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 54 | copies of the Software, and to permit persons to whom the Software is 55 | furnished to do so, subject to the following conditions: 56 | 57 | The above copyright notice and this permission notice shall be included in all 58 | copies or substantial portions of the Software. 59 | 60 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 61 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 62 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 63 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 64 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 65 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 66 | SOFTWARE. 67 | -------------------------------------------------------------------------------- /src/menu.ts: -------------------------------------------------------------------------------- 1 | import { red, blue, green, white, bold, bgRed } from "kleur/colors"; 2 | 3 | export type Item = { 4 | title: string; 5 | meta: Record; 6 | marked?: boolean; 7 | }; 8 | 9 | type menuOptions = { 10 | items: Item[]; 11 | height: number; 12 | id: string; 13 | onSelect: (item: Item) => void; 14 | }; 15 | 16 | export function createMenu(opts: menuOptions) { 17 | let { items, height, id, onSelect } = opts; 18 | let selected = 0; 19 | 20 | function fillSpace() { 21 | if (items.length < height) { 22 | Array.from(Array(height - items.length)).forEach(() => console.log()); 23 | } 24 | } 25 | 26 | function getId() { 27 | return id; 28 | } 29 | 30 | function setItems(newItems: Item[]) { 31 | items = newItems; 32 | } 33 | 34 | function render(highlightSelected = true) { 35 | let start = 0; 36 | let end = height; 37 | 38 | if (selected >= height / 2) { 39 | start = selected - height / 2; 40 | end = selected + height / 2; 41 | 42 | if (end >= items.length - 1) { 43 | start = items.length - 1 - height; 44 | end = items.length - 1; 45 | } 46 | } 47 | 48 | // debug console.log(selected, before, after) 49 | 50 | items.slice(start, end).forEach((item, i) => { 51 | if (highlightSelected && item.title === items[selected].title) { 52 | // console.warn('>', item.title); 53 | console.warn(item.title); 54 | } else if (item.marked) { 55 | console.log(green(item.title)); 56 | } else { 57 | console.log(item.title); 58 | } 59 | }); 60 | 61 | fillSpace(); 62 | 63 | if (items.length && highlightSelected) 64 | console.log(`\n${selected + 1}/${items.length}`); 65 | } 66 | 67 | function next(steps = 1) { 68 | selected += steps; 69 | if (selected > items.length - 1) { 70 | selected = steps > 1 ? items.length - 1 : 0; 71 | } 72 | } 73 | 74 | function prev(steps = 1) { 75 | selected -= steps; 76 | if (selected < 0) selected = steps > 1 ? 0 : items.length - 1; 77 | } 78 | 79 | function getSelected() { 80 | return items[selected]; 81 | } 82 | 83 | function getNext() { 84 | let next = selected + 1; 85 | if (next > items.length - 1) { 86 | next = 0; 87 | } 88 | return items[next]; 89 | } 90 | 91 | function getPrev() { 92 | let prev = selected - 1; 93 | if (prev < 0) { 94 | prev = items.length - 1; 95 | } 96 | return items[prev]; 97 | } 98 | 99 | function select(id?: number) { 100 | const item = items[id || selected]; 101 | onSelect(item); 102 | } 103 | 104 | return { 105 | getSelected, 106 | getNext, 107 | getPrev, 108 | select, 109 | next, 110 | prev, 111 | render, 112 | setItems, 113 | getId, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { red, cyan, green, white, yellow, bgRed } from "kleur/colors"; 2 | 3 | import { cursor, erase } from "sisteransi"; 4 | import { createDownloadManager } from "./download-manager"; 5 | import { fetchProgress } from "./fetch"; 6 | import { input } from "./input"; 7 | import { createMenu, Item } from "./menu"; 8 | import { createScreen } from "./screen"; 9 | import { download, fetchArchiveList } from "./services/archive.service"; 10 | import { notification } from "./utils/notification"; 11 | import { config } from "./config"; 12 | import { loading } from "./loading"; 13 | 14 | type Collection = { 15 | title: string; 16 | archiveName: string; 17 | }; 18 | 19 | const VISIBLE_MENU_ITEMS = 22; 20 | 21 | let currentMenu; 22 | let mainMenu; 23 | let currentCollection; 24 | 25 | let isButtonDownPressed = false; 26 | let isButtonUpPressed = false; 27 | 28 | const downloadManager = createDownloadManager(); 29 | const screen = createScreen(80, 43); 30 | 31 | const header = screen.spread3({ 32 | left: { text: "", color: white }, 33 | center: { text: "nx-archive-browser v0.1.3", color: yellow }, 34 | right: { text: "+ Exit ", color: white }, 35 | }); 36 | 37 | async function main() { 38 | // init local-storage to get the profile prompt directly on start 39 | localStorage.setItem("init", "true"); 40 | 41 | const collections = await config.get("collections"); 42 | 43 | const collectionItems: Item[] = Object.entries(collections).map( 44 | ([title, archiveName]) => ({ 45 | title, 46 | meta: { 47 | title, 48 | archiveName, 49 | }, 50 | }) 51 | ); 52 | 53 | mainMenu = createMenu({ 54 | id: "main", 55 | items: collectionItems, 56 | height: VISIBLE_MENU_ITEMS, 57 | onSelect: async (item) => { 58 | try { 59 | await enterCollection(item.meta as Collection, () => { 60 | currentMenu = mainMenu!; 61 | }); 62 | currentCollection = item.meta.title; 63 | } catch (e) { 64 | loading.stop(); 65 | notification.show(4000, "Error loading collection"); 66 | currentMenu = mainMenu!; 67 | } 68 | }, 69 | }); 70 | currentMenu = mainMenu; 71 | 72 | requestAnimationFrame(loop); 73 | 74 | let timeout; 75 | input({ 76 | onButtonDown: (key: string) => { 77 | if (loading.isLoading()) return; 78 | if (key === "up") { 79 | currentMenu!.prev(); 80 | timeout = setTimeout(() => { 81 | isButtonUpPressed = true; 82 | }, 500); 83 | } 84 | if (key === "down") { 85 | currentMenu!.next(); 86 | 87 | timeout = setTimeout(() => { 88 | isButtonDownPressed = true; 89 | }, 500); 90 | } 91 | if (key === "left") { 92 | currentMenu!.prev(VISIBLE_MENU_ITEMS); 93 | } 94 | if (key === "right") { 95 | currentMenu!.next(VISIBLE_MENU_ITEMS); 96 | } 97 | if (key === "L") { 98 | if (currentMenu!.getId() === "collection") { 99 | mainMenu!.prev(); 100 | mainMenu!.select(); 101 | } 102 | } 103 | if (key === "R") { 104 | if (currentMenu!.getId() === "collection") { 105 | mainMenu!.next(); 106 | mainMenu!.select(); 107 | } 108 | } 109 | if (key === "A") { 110 | currentMenu!.select(); 111 | } 112 | if (key === "B") { 113 | currentMenu = mainMenu!; 114 | } 115 | if (key === "Y") { 116 | localStorage.clear(); 117 | notification.show(2000, "cache cleared!"); 118 | } 119 | if (key === "X") { 120 | if (currentMenu!.getId() === "collection") { 121 | const item = currentMenu!.getSelected(); 122 | if (!item.marked) return; 123 | 124 | remove(item.meta.collection.title, item.meta.fileName); 125 | item.marked = false; 126 | } 127 | } 128 | }, 129 | onButtonUp: (key: string) => { 130 | if (loading.isLoading()) return; 131 | if (key === "up") { 132 | clearTimeout(timeout!); 133 | 134 | isButtonUpPressed = false; 135 | } 136 | if (key === "down") { 137 | clearTimeout(timeout!); 138 | 139 | isButtonDownPressed = false; 140 | } 141 | }, 142 | }); 143 | } 144 | 145 | async function listLocalFiles(collection: string) { 146 | const folder = await config.get("folder"); 147 | return Switch.readDirSync(`sdmc:/${folder}/${collection}`) || []; 148 | } 149 | 150 | async function remove(collection: string, fileName: string) { 151 | const folder = await config.get("folder"); 152 | 153 | const path = `sdmc:/${folder}/${collection}/${fileName}`; 154 | Switch.removeSync(path); 155 | notification.show(2000, `${fileName} deleted!`); 156 | } 157 | 158 | async function handleDownload(collection: Collection, item: Item) { 159 | const folder = await config.get("folder"); 160 | 161 | try { 162 | await download( 163 | collection, 164 | item.meta.fileName, 165 | `${folder}/${collection.title}`, 166 | { 167 | onDownloadStart: () => { 168 | downloadManager.add(item.meta.fileName); 169 | }, 170 | onProgress: (p) => { 171 | downloadManager.update(item.meta.fileName, p.progress, p.speed); 172 | }, 173 | } 174 | ); 175 | item.marked = true; 176 | } catch (e) { 177 | downloadManager.add(item.meta.fileName, e as string); 178 | } 179 | } 180 | 181 | async function enterCollection(collection: Collection, onExit: () => void) { 182 | loading.start(`Fetching collection: ${collection.title} ...`); 183 | const cached = localStorage.getItem(collection.archiveName); 184 | const entries = cached 185 | ? JSON.parse(cached) 186 | : await fetchArchiveList(collection.archiveName); 187 | 188 | localStorage.setItem(collection.archiveName, JSON.stringify(entries)); 189 | 190 | const localFiles = await listLocalFiles(collection.title); 191 | 192 | const titles = entries.map((entry) => ({ 193 | meta: { 194 | fileName: entry.title, 195 | collection, 196 | }, 197 | marked: localFiles.includes(entry.title), 198 | title: entry.title.slice(0, 79), 199 | })); 200 | 201 | currentMenu = createMenu({ 202 | id: "collection", 203 | items: [ 204 | { 205 | title: "<", 206 | }, 207 | ...titles, 208 | ], 209 | height: VISIBLE_MENU_ITEMS, 210 | onSelect: (item: Item) => { 211 | if (item.title === "<") { 212 | return onExit(); 213 | } 214 | handleDownload(collection, item); 215 | }, 216 | }); 217 | 218 | loading.stop(); 219 | } 220 | 221 | function render() { 222 | console.log(erase.screen); 223 | 224 | console.log(header); 225 | 226 | console.log(""); 227 | 228 | if (currentMenu!.getId() === "collection") { 229 | console.log( 230 | screen.spread3({ 231 | left: { text: ` L ${mainMenu!.getPrev().title}`, color: white }, 232 | center: { text: currentCollection!, color: cyan }, 233 | right: { text: `${mainMenu!.getNext().title} R `, color: white }, 234 | }) 235 | ); 236 | } else { 237 | console.log(""); 238 | console.log(""); 239 | } 240 | console.log(""); 241 | 242 | if (loading.isLoading()) { 243 | loading.render(); 244 | console.log(""); 245 | } else { 246 | currentMenu!.render(); 247 | } 248 | 249 | console.log(screen.right(notification.toString() + " ")); 250 | console.log( 251 | screen.centerText( 252 | "__________________________________ Downloads ___________________________________" 253 | ) 254 | ); 255 | console.log(""); 256 | 257 | downloadManager.render(); 258 | 259 | console.log( 260 | "________________________________________________________________________________" 261 | ); 262 | 263 | if (currentMenu!.getId() === "collection") { 264 | console.log( 265 | screen.spread2({ 266 | left: { text: " + Nav A Download B Home X Del", color: white }, 267 | right: { text: "github.com/mklan 2024 ", color: white }, 268 | }) 269 | ); 270 | } else { 271 | console.log( 272 | screen.spread2({ 273 | left: { text: " + Nav A Enter Y clear cache", color: white }, 274 | right: { text: "github.com/mklan 2024 ", color: white }, 275 | }) 276 | ); 277 | } 278 | } 279 | 280 | function loop() { 281 | if (isButtonUpPressed) { 282 | currentMenu!.prev(); 283 | } 284 | if (isButtonDownPressed) { 285 | currentMenu!.next(); 286 | } 287 | 288 | render(); 289 | requestAnimationFrame(loop); 290 | } 291 | 292 | main(); 293 | --------------------------------------------------------------------------------