@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.
- package/README.md +46 -0
- package/colors.ts +35 -0
- package/index.ts +245 -0
- 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
|
+
}
|