@frixaco/anitrack 0.0.1 → 0.0.2
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/README.md +5 -1
- package/index.ts +162 -195
- 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.
|
|
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
|
-
|
|
5
|
-
|
|
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:
|
|
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
|
-
|
|
37
|
+
// --- State ---
|
|
38
|
+
const results = $<ScrapeResultItem[]>([]);
|
|
39
|
+
const loading = $(false);
|
|
40
|
+
const selectedIndex = $(0);
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.2",
|
|
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.
|
|
17
|
+
"@frixaco/letui": "^0.0.7"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/bun": "
|
|
20
|
+
"@types/bun": "1.3.5"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"typescript": "^5"
|