@frixaco/anitrack 0.0.1

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 (4) hide show
  1. package/README.md +46 -0
  2. package/colors.ts +35 -0
  3. package/index.ts +245 -0
  4. package/package.json +25 -0
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # Anitrack - Simple TUI to stream anime
2
+
3
+ The project has been ported multiple times as I kept exploring different technologies for fun:
4
+
5
+ - Next.js app (auth, search, tracker, stream, etc.)
6
+ - Go TUI using Bubbletea
7
+ - Python TUI using Textual
8
+ - `letui` TUI - my own TUI library written from scratch using TypeScript (Bun) and Rust
9
+
10
+ ### Bun+Rust TUI
11
+
12
+ 1. Install Bun (recommended) or Node
13
+ 2. bunx @frixaco/anitrack
14
+
15
+ ### Python TUI
16
+
17
+ 1. Install `uv` (https://docs.astral.sh/uv/getting-started/installation/)
18
+ 2. Install [mpv](github.com/mpv-player/mpv) player.
19
+ 3. (Optional) On desktop, set up **Anime4K** anime upscaler for **mpv**: https://github.com/bloc97/anime4k
20
+ 4. Run `uv tool install frixa-anitrack`
21
+ 5. Run `anitrack`
22
+
23
+ NOTE: to try without installing, run `uv tool run --from frixa-anitrack anitrack` or `uvx --from frixa-anitrack anitrack`
24
+
25
+ Status: finished.
26
+
27
+ ### Go TUI
28
+
29
+ 1. Install Go. I used +1.23.
30
+ 2. Install [mpv](github.com/mpv-player/mpv) player.
31
+ 3. (Optional) On desktop, set up **Anime4K** anime upscaler for **mpv**: https://github.com/bloc97/anime4k
32
+ 4. Run `go install github.com/frixaco/anitrack@latest` or download the binary from releases
33
+
34
+ Status: finished.
35
+
36
+ ### Next.js
37
+
38
+ Fully functional, but requires setting up environment variables and services.
39
+
40
+ Status: abandoned.
41
+
42
+ ### Rust TUI
43
+
44
+ Explored scraping HTML pages a bit. Don't plan to continue for the time being.
45
+
46
+ Status: dormant.
package/colors.ts ADDED
@@ -0,0 +1,35 @@
1
+ export const COLORS = {
2
+ default: {
3
+ bg: 0x16181a,
4
+ bg_alt: 0x1e2124,
5
+ bg_highlight: 0x3c4048,
6
+ fg: 0xffffff,
7
+ grey: 0x7b8496,
8
+ blue: 0x5ea1ff,
9
+ green: 0x5eff6c,
10
+ cyan: 0x5ef1ff,
11
+ red: 0xff6e5e,
12
+ yellow: 0xf1ff5e,
13
+ magenta: 0xff5ef1,
14
+ pink: 0xff5ea0,
15
+ orange: 0xffbd5e,
16
+ purple: 0xbd5eff,
17
+ },
18
+ light: {
19
+ bg: 0xffffff,
20
+ bg_alt: 0xeaeaea,
21
+ bg_highlight: 0xacacac,
22
+ fg: 0x16181a,
23
+ grey: 0x7b8496,
24
+ blue: 0x0057d1,
25
+ green: 0x008b0c,
26
+ cyan: 0x008c99,
27
+ red: 0xd11500,
28
+ yellow: 0x997b00,
29
+ magenta: 0xd100bf,
30
+ pink: 0xf40064,
31
+ orange: 0xd17c00,
32
+ purple: 0xa018ff,
33
+ },
34
+ } as const;
35
+
package/index.ts ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env bun
2
+ 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
+ };
27
+
28
+ type ScrapeResultItem = {
29
+ title: string;
30
+ size: string;
31
+ date: string;
32
+ magnet: string;
33
+ };
34
+
35
+ type ScrapeResult = {
36
+ results: ScrapeResultItem[];
37
+ };
38
+
39
+ type TorrentFile = {};
40
+
41
+ type TorrentDetails = {
42
+ id: number;
43
+ info_hash: string;
44
+ name: string;
45
+ files: TorrentFile[];
46
+ };
47
+
48
+ type TorrentResponse = {
49
+ id: number;
50
+ details: TorrentDetails;
51
+ };
52
+
53
+ let logFile = Bun.file("logs.txt");
54
+
55
+ export function log(txt: string, ...args: string[]) {
56
+ logFile.write(txt + " " + args.join(" "));
57
+ }
58
+
59
+ async function fetchResults(query: string) {
60
+ const response = await fetch(
61
+ `https://scrape.anitrack.frixaco.com/scrape?q=${query}`,
62
+ );
63
+ const data = (await response.json()) as ScrapeResult;
64
+
65
+ log(JSON.stringify(data.results, null, 2));
66
+ results(data.results);
67
+ page(0);
68
+ if (data.results.length > 0) {
69
+ focusId("result-button-0");
70
+ }
71
+ }
72
+
73
+ async function streamResult(magnet: string) {
74
+ const response = await fetch("https://rqbit.anitrack.frixaco.com/torrents", {
75
+ method: "post",
76
+ body: magnet,
77
+ });
78
+ const data = (await response.json()) as TorrentResponse;
79
+ let streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${data.details.info_hash}/stream/${
80
+ data.details.files.length - 1
81
+ }`;
82
+ Bun.spawn({
83
+ cmd: ["mpv", streamUrl],
84
+ stdout: "ignore",
85
+ stderr: "ignore",
86
+ });
87
+ }
88
+
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
+ );
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@frixaco/anitrack",
3
+ "version": "0.0.1",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "bin": {
7
+ "anitrack": "./index.ts"
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "colors.ts"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@frixaco/letui": "^0.0.4"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "latest"
21
+ },
22
+ "peerDependencies": {
23
+ "typescript": "^5"
24
+ }
25
+ }