@frixaco/anitrack 0.0.1 → 0.0.3

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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/index.ts +162 -195
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -10,7 +10,11 @@ The project has been ported multiple times as I kept exploring different technol
10
10
  ### Bun+Rust TUI
11
11
 
12
12
  1. Install Bun (recommended) or Node
13
- 2. bunx @frixaco/anitrack
13
+ 2. Install [mpv](github.com/mpv-player/mpv) player.
14
+ 3. (Optional) On desktop, set up **Anime4K** anime upscaler for **mpv**: https://github.com/bloc97/anime4k
15
+ 4. `bunx @frixaco/anitrack`
16
+
17
+ Status: almost finished
14
18
 
15
19
  ### Python TUI
16
20
 
package/index.ts CHANGED
@@ -1,30 +1,16 @@
1
1
  #!/usr/bin/env bun
2
+ // TORRENT SEARCH APP
3
+ // - Search input with loading bar
4
+ // - Virtual windowing results list
5
+ // - Keyboard navigation (j/k/h/l)
6
+
7
+ import { existsSync } from "fs";
2
8
  import { COLORS } from "./colors";
3
- import {
4
- Button,
5
- Column,
6
- InputBox,
7
- Row,
8
- run,
9
- type InputBoxProps,
10
- $,
11
- } from "@frixaco/letui";
12
-
13
- let searchText = $("");
14
- let focusId = $("search-input");
15
- // let buttonText = $("Search");
16
- let results = $<ScrapeResultItem[]>([]);
17
- let maxItems = $(1);
18
- let page = $(0);
19
-
20
- let inputStyles: Partial<InputBoxProps> = {
21
- border: {
22
- color: COLORS.default.fg,
23
- style: "square",
24
- },
25
- padding: "1 0",
26
- };
9
+ import { Button, Column, Input, Row, run, onKey } from "@frixaco/letui";
10
+ import { LoadingBar } from "@frixaco/letui/progress-bar";
11
+ import { $, ff, whenSettled } from "@frixaco/letui/signals";
27
12
 
13
+ // --- Types ---
28
14
  type ScrapeResultItem = {
29
15
  title: string;
30
16
  size: string;
@@ -36,13 +22,11 @@ type ScrapeResult = {
36
22
  results: ScrapeResultItem[];
37
23
  };
38
24
 
39
- type TorrentFile = {};
40
-
41
25
  type TorrentDetails = {
42
26
  id: number;
43
27
  info_hash: string;
44
28
  name: string;
45
- files: TorrentFile[];
29
+ files: unknown[];
46
30
  };
47
31
 
48
32
  type TorrentResponse = {
@@ -50,196 +34,179 @@ type TorrentResponse = {
50
34
  details: TorrentDetails;
51
35
  };
52
36
 
53
- let logFile = Bun.file("logs.txt");
37
+ // --- State ---
38
+ const results = $<ScrapeResultItem[]>([]);
39
+ const loading = $(false);
40
+ const selectedIndex = $(0);
54
41
 
55
- export function log(txt: string, ...args: string[]) {
56
- logFile.write(txt + " " + args.join(" "));
57
- }
42
+ // --- Loading Bars ---
43
+ const loadingBar = LoadingBar({
44
+ dotColor: COLORS.default.green,
45
+ trackColor: COLORS.default.bg_alt,
46
+ });
58
47
 
48
+ // --- API ---
59
49
  async function fetchResults(query: string) {
50
+ loading(true);
51
+ loadingBar.start();
52
+
60
53
  const response = await fetch(
61
54
  `https://scrape.anitrack.frixaco.com/scrape?q=${query}`,
62
55
  );
63
56
  const data = (await response.json()) as ScrapeResult;
64
57
 
65
- log(JSON.stringify(data.results, null, 2));
66
58
  results(data.results);
67
- page(0);
68
- if (data.results.length > 0) {
69
- focusId("result-button-0");
70
- }
59
+ selectedIndex(0);
60
+ loading(false);
61
+ loadingBar.stop();
71
62
  }
72
63
 
73
64
  async function streamResult(magnet: string) {
65
+ loadingBar.start();
66
+
74
67
  const response = await fetch("https://rqbit.anitrack.frixaco.com/torrents", {
75
68
  method: "post",
76
69
  body: magnet,
77
70
  });
78
71
  const data = (await response.json()) as TorrentResponse;
79
- let streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${data.details.info_hash}/stream/${
72
+ const streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${data.details.info_hash}/stream/${
80
73
  data.details.files.length - 1
81
74
  }`;
75
+
76
+ const ipcPath = `/tmp/mpv-socket-${Date.now()}`;
82
77
  Bun.spawn({
83
- cmd: ["mpv", streamUrl],
78
+ cmd: ["mpv", `--input-ipc-server=${ipcPath}`, streamUrl],
84
79
  stdout: "ignore",
85
80
  stderr: "ignore",
86
81
  });
82
+
83
+ // Poll until socket exists (mpv fully initialized)
84
+ while (!existsSync(ipcPath)) {
85
+ await Bun.sleep(50);
86
+ }
87
+
88
+ loadingBar.stop();
89
+ }
90
+
91
+ // --- Styles ---
92
+ const borderStyle = {
93
+ color: COLORS.default.fg,
94
+ style: "square" as const,
95
+ };
96
+
97
+ const focusedBorderStyle = {
98
+ color: COLORS.default.green,
99
+ style: "square" as const,
100
+ };
101
+
102
+ // --- Nodes ---
103
+ const searchInput = Input({
104
+ placeholder: "Search torrents...",
105
+ border: borderStyle,
106
+ padding: "1 0",
107
+ onSubmit: (val) => fetchResults(val),
108
+ onFocus: (self) => self.setStyle({ border: focusedBorderStyle }),
109
+ onBlur: (self) => self.setStyle({ border: borderStyle }),
110
+ });
111
+ whenSettled(() => searchInput.focus());
112
+
113
+ const loadingBars = Row({ flexGrow: 1 }, [loadingBar.node]);
114
+
115
+ const resultsList = Column({ gap: 1, padding: "1 0", flexGrow: 1 }, []);
116
+
117
+ const root = Column({ border: borderStyle, gap: 1, padding: "1 0" }, [
118
+ Column({ padding: "1 0" }, [searchInput, loadingBars]),
119
+ resultsList,
120
+ ]);
121
+
122
+ // --- Keep track of result buttons for focus management ---
123
+ let resultButtons: ReturnType<typeof Button>[] = [];
124
+
125
+ // --- Reactive effects ---
126
+
127
+ // Update results list with virtual windowing
128
+ ff(() => {
129
+ const all = results();
130
+ const selected = selectedIndex();
131
+
132
+ if (all.length === 0) {
133
+ resultsList.setChildren?.([]);
134
+ return;
135
+ }
136
+
137
+ // Use actual computed frame height from Taffy
138
+ const availableHeight = resultsList.frameHeight();
139
+
140
+ // Each item: border(2) + text(1) = 3, plus gap(1) between items
141
+ const itemHeight = 3;
142
+ const visibleCount = Math.max(1, Math.floor(availableHeight / itemHeight));
143
+
144
+ // Calculate window with selection at bottom (scroll only when needed)
145
+ let start = selected - visibleCount + 1;
146
+ start = Math.max(0, Math.min(start, all.length - visibleCount));
147
+ const end = Math.min(start + visibleCount, all.length);
148
+ const visible = all.slice(start, end);
149
+
150
+ resultButtons = visible.map((item, i) => {
151
+ const globalIdx = start + i;
152
+ const isActive = globalIdx === selected;
153
+ return Button({
154
+ text: `${isActive ? "▶ " : " "}${item.title}`,
155
+ border: isActive ? focusedBorderStyle : borderStyle,
156
+ padding: "1 0",
157
+ onClick: () => streamResult(item.magnet),
158
+ });
159
+ });
160
+
161
+ resultsList.setChildren?.(resultButtons);
162
+
163
+ // Focus the selected button
164
+ const selectedVisibleIndex = selected - start;
165
+ if (resultButtons[selectedVisibleIndex]) {
166
+ resultButtons[selectedVisibleIndex].focus();
167
+ }
168
+ });
169
+
170
+ // --- Keyboard navigation ---
171
+ onKey("/", () => searchInput.focus());
172
+
173
+ onKey("j", () => selectNext());
174
+ onKey("\x1b[B", () => selectNext()); // Arrow Down
175
+
176
+ onKey("k", () => selectPrev());
177
+ onKey("\x1b[A", () => selectPrev()); // Arrow Up
178
+
179
+ onKey("l", () => selectLast());
180
+ onKey("\x1b[C", () => selectLast()); // Arrow Right - jump to end
181
+
182
+ onKey("h", () => selectFirst());
183
+ onKey("\x1b[D", () => selectFirst()); // Arrow Left - jump to start
184
+
185
+ onKey("q", () => app.quit());
186
+
187
+ function selectNext() {
188
+ const max = results().length - 1;
189
+ if (selectedIndex() < max) {
190
+ selectedIndex(selectedIndex() + 1);
191
+ }
192
+ }
193
+
194
+ function selectPrev() {
195
+ if (selectedIndex() > 0) {
196
+ selectedIndex(selectedIndex() - 1);
197
+ }
198
+ }
199
+
200
+ function selectFirst() {
201
+ selectedIndex(0);
202
+ }
203
+
204
+ function selectLast() {
205
+ const max = results().length - 1;
206
+ if (max >= 0) {
207
+ selectedIndex(max);
208
+ }
87
209
  }
88
210
 
89
- run(
90
- (terminalWidth: number, termianlHeight: number) =>
91
- Column(
92
- {
93
- border: {
94
- color: COLORS.default.fg,
95
- style: "square",
96
- },
97
- gap: 1,
98
- padding: "1 0",
99
- },
100
- [
101
- Row(
102
- {
103
- border: "none",
104
- gap: 1,
105
- padding: "1 0",
106
- },
107
- [
108
- InputBox({
109
- ...inputStyles,
110
- id: "search-input",
111
- focus: true,
112
- text: searchText,
113
- border: {
114
- color:
115
- focusId() === "search-input"
116
- ? COLORS.default.green
117
- : COLORS.default.fg,
118
- style: "square",
119
- },
120
- onType: (v) => {
121
- searchText(v);
122
- },
123
- onBlur: () => {},
124
- onFocus: () => {},
125
- onSubmit: (v) => {
126
- log("onSubmit +" + v);
127
- fetchResults(v);
128
- },
129
- }),
130
-
131
- // Button({
132
- // ...inputStyles,
133
- // id: "search-button",
134
- // text: buttonText,
135
- // onClick: () => {
136
- // log("onSubmit +" + searchText());
137
- // fetchResults(searchText());
138
- // },
139
- // }),
140
- ],
141
- ),
142
-
143
- Column(
144
- {
145
- border: "none",
146
- gap: 1,
147
- padding: "1 0",
148
- onLayout: (node) => {
149
- const h = node.frame.height;
150
- const child = node.children[0];
151
- if (!child) return;
152
-
153
- const childH = child.frame.height;
154
- if (h && childH) {
155
- // Calculate available height by subtracting vertical padding and border
156
- let paddingY = 0;
157
- const { padding } = node.props;
158
- if (typeof padding === "number") {
159
- paddingY = padding;
160
- } else if (typeof padding === "string") {
161
- const parts = padding.split(" ").map(Number);
162
- paddingY = parts.length === 2 ? parts[0]! : parts[0]!;
163
- }
164
-
165
- let borderY = 0;
166
- const { border } = node.props;
167
- if (border && border !== "none") {
168
- borderY = 1;
169
- }
170
-
171
- const availableH = h - paddingY * 2 - borderY * 2;
172
-
173
- const gap = (node.props as any).gap || 0;
174
- log(
175
- JSON.stringify({
176
- childH,
177
- availableH,
178
- gap,
179
- }),
180
- );
181
- const capacity = Math.ceil(availableH / childH);
182
-
183
- if (maxItems() !== capacity && capacity > 0) {
184
- maxItems(capacity);
185
- }
186
- }
187
- },
188
- },
189
- results()
190
- .slice(page() * maxItems(), (page() + 1) * maxItems())
191
- .map((s, i) => {
192
- let text = $(s.title);
193
- let id = `result-button-${i}`;
194
-
195
- return Button({
196
- ...inputStyles,
197
- id,
198
- text: text,
199
- border: {
200
- color:
201
- focusId() === id ? COLORS.default.green : COLORS.default.fg,
202
- style: "square",
203
- },
204
- onClick: () => {
205
- streamResult(s.magnet);
206
- log(`Clicked: ${s.title}`);
207
- },
208
- onKeyDown: (key) => {
209
- const totalPages = Math.ceil(results().length / maxItems());
210
- const currentPageSize = results().slice(
211
- page() * maxItems(),
212
- (page() + 1) * maxItems(),
213
- ).length;
214
- const currentIndex = i;
215
-
216
- if (key === "l" || key === "\u001b[C") {
217
- if (page() < totalPages - 1) {
218
- page(page() + 1);
219
- focusId("result-button-0");
220
- }
221
- } else if (key === "h" || key === "\u001b[D") {
222
- if (page() > 0) {
223
- page(page() - 1);
224
- focusId("result-button-0");
225
- }
226
- } else if (key === "j" || key === "\u001b[B") {
227
- if (currentIndex < currentPageSize - 1) {
228
- focusId(`result-button-${currentIndex + 1}`);
229
- }
230
- } else if (key === "k" || key === "\u001b[A") {
231
- if (currentIndex > 0) {
232
- focusId(`result-button-${currentIndex - 1}`);
233
- } else {
234
- focusId("search-input");
235
- }
236
- }
237
- },
238
- });
239
- }),
240
- ),
241
- ],
242
- ),
243
- [results, focusId, maxItems, page],
244
- focusId,
245
- );
211
+ // --- Start app ---
212
+ const app = run(root, { debug: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frixaco/anitrack",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,10 +14,10 @@
14
14
  "dev": "bun run index.ts"
15
15
  },
16
16
  "dependencies": {
17
- "@frixaco/letui": "^0.0.4"
17
+ "@frixaco/letui": "^0.0.8"
18
18
  },
19
19
  "devDependencies": {
20
- "@types/bun": "latest"
20
+ "@types/bun": "1.3.5"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "typescript": "^5"