twitchdropsminer-cli 0.1.0
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 +153 -0
- package/dist/auth/cookieImport.js +7 -0
- package/dist/auth/deviceAuth.js +61 -0
- package/dist/auth/sessionManager.js +30 -0
- package/dist/auth/tokenImport.js +21 -0
- package/dist/auth/validate.js +29 -0
- package/dist/cli/commands/auth.js +172 -0
- package/dist/cli/commands/config.js +51 -0
- package/dist/cli/commands/doctor.js +49 -0
- package/dist/cli/commands/games.js +79 -0
- package/dist/cli/commands/healthcheck.js +25 -0
- package/dist/cli/commands/logs.js +14 -0
- package/dist/cli/commands/run.js +20 -0
- package/dist/cli/commands/service.js +85 -0
- package/dist/cli/commands/status.js +29 -0
- package/dist/cli/contracts/exitCodes.js +7 -0
- package/dist/cli/index.js +36 -0
- package/dist/config/schema.js +16 -0
- package/dist/config/store.js +29 -0
- package/dist/core/channelService.js +105 -0
- package/dist/core/constants.js +12 -0
- package/dist/core/maintenance.js +15 -0
- package/dist/core/miner.js +366 -0
- package/dist/core/runtime.js +34 -0
- package/dist/core/stateMachine.js +9 -0
- package/dist/core/watchLoop.js +26 -0
- package/dist/domain/channel.js +31 -0
- package/dist/domain/inventory.js +370 -0
- package/dist/integrations/gqlClient.js +13 -0
- package/dist/integrations/gqlOperations.js +42 -0
- package/dist/integrations/httpClient.js +37 -0
- package/dist/integrations/twitchPubSub.js +126 -0
- package/dist/integrations/twitchSpade.js +112 -0
- package/dist/ops/systemd.js +63 -0
- package/dist/state/authStore.js +38 -0
- package/dist/state/cookieStore.js +21 -0
- package/dist/state/sessionState.js +26 -0
- package/dist/tests/index.js +7 -0
- package/dist/tests/integration/configStore.test.js +8 -0
- package/dist/tests/parity/stateMachineFlow.test.js +14 -0
- package/dist/tests/unit/channel.test.js +73 -0
- package/dist/tests/unit/channelService.test.js +41 -0
- package/dist/tests/unit/dropsDomain.test.js +57 -0
- package/dist/tests/unit/tokenImport.test.js +13 -0
- package/dist/tests/unit/twitchSpade.test.js +24 -0
- package/docs/ops/authentication.md +32 -0
- package/docs/ops/drops-validation.md +73 -0
- package/docs/ops/linux-install.md +15 -0
- package/docs/ops/service-management.md +23 -0
- package/docs/ops/systemd-hardening.md +13 -0
- package/package.json +41 -0
- package/resources/systemd/tdm.service.tpl +17 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import { loadAuthState } from "../../state/authStore.js";
|
|
3
|
+
import { EXIT_AUTH_MISSING, EXIT_OK } from "../contracts/exitCodes.js";
|
|
4
|
+
export const healthcheckCommand = new Command("healthcheck")
|
|
5
|
+
.description("Return a simple machine-readable health status for monitoring")
|
|
6
|
+
.option("--json", "Output health as JSON", false)
|
|
7
|
+
.action(async (opts) => {
|
|
8
|
+
const auth = loadAuthState();
|
|
9
|
+
const healthy = !!auth && (!!auth.accessToken || !!auth.cookiesHeader);
|
|
10
|
+
if (opts.json) {
|
|
11
|
+
// eslint-disable-next-line no-console
|
|
12
|
+
console.log(JSON.stringify({
|
|
13
|
+
status: healthy ? "ok" : "degraded",
|
|
14
|
+
auth: {
|
|
15
|
+
hasToken: !!auth?.accessToken,
|
|
16
|
+
hasCookies: !!auth?.cookiesHeader
|
|
17
|
+
}
|
|
18
|
+
}, null, 2));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
// eslint-disable-next-line no-console
|
|
22
|
+
console.log(healthy ? "OK" : "DEGRADED");
|
|
23
|
+
}
|
|
24
|
+
process.exitCode = healthy ? EXIT_OK : EXIT_AUTH_MISSING;
|
|
25
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
export const logsCommand = new Command("logs")
|
|
4
|
+
.description("Follow service logs via journalctl")
|
|
5
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
6
|
+
.option("--user", "Use user journal", true)
|
|
7
|
+
.option("--follow", "Follow logs", true)
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const args = [...(opts.user ? ["--user"] : []), "-u", opts.name, ...(opts.follow ? ["-f"] : [])];
|
|
10
|
+
await new Promise((resolve) => {
|
|
11
|
+
const proc = spawn("journalctl", args, { stdio: "inherit" });
|
|
12
|
+
proc.on("close", () => resolve());
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import { Miner } from "../../core/miner.js";
|
|
3
|
+
import { logger } from "../../core/runtime.js";
|
|
4
|
+
export const runCommand = new Command("run")
|
|
5
|
+
.description("Run the Twitch drops miner")
|
|
6
|
+
.option("-v, --verbose", "Enable verbose logging")
|
|
7
|
+
.option("--dry-run", "Log intended actions only; no spade POST or claim GQL")
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
if (opts.verbose) {
|
|
10
|
+
process.env.TDM_LOG_LEVEL = "debug";
|
|
11
|
+
}
|
|
12
|
+
const miner = new Miner();
|
|
13
|
+
if (opts.verbose) {
|
|
14
|
+
logger.info("Starting TwitchDropsMiner CLI in verbose mode.");
|
|
15
|
+
}
|
|
16
|
+
if (opts.dryRun) {
|
|
17
|
+
logger.info("Dry-run mode: no spade or claim network writes.");
|
|
18
|
+
}
|
|
19
|
+
await miner.run({ dryRun: opts.dryRun ?? false });
|
|
20
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import { writeUnitFile, systemctl } from "../../ops/systemd.js";
|
|
3
|
+
import { EXIT_OK, EXIT_SERVICE_ERROR } from "../contracts/exitCodes.js";
|
|
4
|
+
export const serviceCommand = new Command("service")
|
|
5
|
+
.description("Manage TwitchDropsMiner CLI as a systemd service")
|
|
6
|
+
.option("--user", "Use user-level systemd unit (recommended)", true)
|
|
7
|
+
.option("--system", "Use system-level systemd unit (requires root)", false);
|
|
8
|
+
serviceCommand
|
|
9
|
+
.command("install")
|
|
10
|
+
.description("Install the systemd unit")
|
|
11
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
12
|
+
.option("--autostart", "Enable service at boot", true)
|
|
13
|
+
.action(async (opts, cmd) => {
|
|
14
|
+
const parent = cmd.parent;
|
|
15
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
16
|
+
const unitPath = writeUnitFile({
|
|
17
|
+
userUnit,
|
|
18
|
+
autostart: opts.autostart,
|
|
19
|
+
serviceName: opts.name
|
|
20
|
+
});
|
|
21
|
+
// eslint-disable-next-line no-console
|
|
22
|
+
console.log(`Installed unit at: ${unitPath}`);
|
|
23
|
+
if (opts.autostart) {
|
|
24
|
+
const code = await systemctl(["enable", opts.name], userUnit);
|
|
25
|
+
if (code !== 0) {
|
|
26
|
+
process.exitCode = EXIT_SERVICE_ERROR;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
process.exitCode = EXIT_OK;
|
|
31
|
+
});
|
|
32
|
+
serviceCommand
|
|
33
|
+
.command("start")
|
|
34
|
+
.description("Start the service")
|
|
35
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
36
|
+
.action(async (opts, cmd) => {
|
|
37
|
+
const parent = cmd.parent;
|
|
38
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
39
|
+
const code = await systemctl(["start", opts.name], userUnit);
|
|
40
|
+
process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
|
|
41
|
+
});
|
|
42
|
+
serviceCommand
|
|
43
|
+
.command("stop")
|
|
44
|
+
.description("Stop the service")
|
|
45
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
46
|
+
.action(async (opts, cmd) => {
|
|
47
|
+
const parent = cmd.parent;
|
|
48
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
49
|
+
const code = await systemctl(["stop", opts.name], userUnit);
|
|
50
|
+
process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
|
|
51
|
+
});
|
|
52
|
+
serviceCommand
|
|
53
|
+
.command("restart")
|
|
54
|
+
.description("Restart the service")
|
|
55
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
56
|
+
.action(async (opts, cmd) => {
|
|
57
|
+
const parent = cmd.parent;
|
|
58
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
59
|
+
const code = await systemctl(["restart", opts.name], userUnit);
|
|
60
|
+
process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
|
|
61
|
+
});
|
|
62
|
+
serviceCommand
|
|
63
|
+
.command("status")
|
|
64
|
+
.description("Show service status")
|
|
65
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
66
|
+
.action(async (opts, cmd) => {
|
|
67
|
+
const parent = cmd.parent;
|
|
68
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
69
|
+
const code = await systemctl(["status", opts.name], userUnit);
|
|
70
|
+
process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
|
|
71
|
+
});
|
|
72
|
+
serviceCommand
|
|
73
|
+
.command("uninstall")
|
|
74
|
+
.description("Disable and remove the systemd unit")
|
|
75
|
+
.option("--name <name>", "Service unit name", "tdm.service")
|
|
76
|
+
.action(async (opts, cmd) => {
|
|
77
|
+
const parent = cmd.parent;
|
|
78
|
+
const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
|
|
79
|
+
// Best-effort disable; ignore errors.
|
|
80
|
+
await systemctl(["disable", opts.name], userUnit);
|
|
81
|
+
// Unit file removal is left to the user to avoid accidental deletions outside home.
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.log("Service disabled. To fully remove the unit file, delete it from your systemd directory.");
|
|
84
|
+
process.exitCode = EXIT_OK;
|
|
85
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import { loadSessionState } from "../../state/sessionState.js";
|
|
3
|
+
export const statusCommand = new Command("status")
|
|
4
|
+
.description("Show current miner status")
|
|
5
|
+
.option("--json", "Output status as JSON")
|
|
6
|
+
.action(async (opts) => {
|
|
7
|
+
const session = loadSessionState();
|
|
8
|
+
const rawState = session?.state ?? "UNKNOWN";
|
|
9
|
+
const highLevel = rawState === "IDLE" && session?.watchedChannelName
|
|
10
|
+
? "WATCHING"
|
|
11
|
+
: rawState !== "IDLE" && rawState !== "EXIT"
|
|
12
|
+
? "MAINTENANCE"
|
|
13
|
+
: rawState;
|
|
14
|
+
const status = {
|
|
15
|
+
running: rawState !== "EXIT",
|
|
16
|
+
state: highLevel,
|
|
17
|
+
rawState,
|
|
18
|
+
watchedChannel: session?.watchedChannelName ?? null,
|
|
19
|
+
activeDrop: session?.activeDropId ?? null
|
|
20
|
+
};
|
|
21
|
+
if (opts.json) {
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.log(JSON.stringify(status));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.log(`State=${status.state}, channel=${status.watchedChannel ?? "-"}, activeDrop=${status.activeDrop ?? "-"}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "@commander-js/extra-typings";
|
|
3
|
+
import { runCommand } from "./commands/run.js";
|
|
4
|
+
import { authCommand } from "./commands/auth.js";
|
|
5
|
+
import { statusCommand } from "./commands/status.js";
|
|
6
|
+
import { configCommand } from "./commands/config.js";
|
|
7
|
+
import { gamesCommand } from "./commands/games.js";
|
|
8
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
9
|
+
import { healthcheckCommand } from "./commands/healthcheck.js";
|
|
10
|
+
import { serviceCommand } from "./commands/service.js";
|
|
11
|
+
import { logsCommand } from "./commands/logs.js";
|
|
12
|
+
import { ensureSingleInstanceLock } from "../core/runtime.js";
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name("tdm")
|
|
16
|
+
.description("Twitch Drops Miner CLI (headless)")
|
|
17
|
+
.version("0.1.0");
|
|
18
|
+
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
19
|
+
if (actionCommand.name() === "run") {
|
|
20
|
+
ensureSingleInstanceLock();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
program.addCommand(runCommand);
|
|
24
|
+
program.addCommand(authCommand);
|
|
25
|
+
program.addCommand(statusCommand);
|
|
26
|
+
program.addCommand(configCommand);
|
|
27
|
+
program.addCommand(gamesCommand);
|
|
28
|
+
program.addCommand(doctorCommand);
|
|
29
|
+
program.addCommand(healthcheckCommand);
|
|
30
|
+
program.addCommand(serviceCommand);
|
|
31
|
+
program.addCommand(logsCommand);
|
|
32
|
+
program.parseAsync().catch((err) => {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.error("Fatal error:", err);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const PriorityModeSchema = z.enum(["priority_only", "ending_soonest", "low_avbl_first"]);
|
|
3
|
+
export const ConfigSchema = z.object({
|
|
4
|
+
proxy: z.string().default(""),
|
|
5
|
+
language: z.string().default("English"),
|
|
6
|
+
darkMode: z.boolean().default(false),
|
|
7
|
+
exclude: z.array(z.string()).default([]),
|
|
8
|
+
priority: z.array(z.string()).default([]),
|
|
9
|
+
autostartTray: z.boolean().default(false),
|
|
10
|
+
connectionQuality: z.number().int().min(1).max(6).default(1),
|
|
11
|
+
trayNotifications: z.boolean().default(true),
|
|
12
|
+
enableBadgesEmotes: z.boolean().default(false),
|
|
13
|
+
availableDropsCheck: z.boolean().default(false),
|
|
14
|
+
priorityMode: PriorityModeSchema.default("priority_only")
|
|
15
|
+
});
|
|
16
|
+
export const DEFAULT_CONFIG = ConfigSchema.parse({});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ConfigSchema, DEFAULT_CONFIG } from "./schema.js";
|
|
5
|
+
function configDir() {
|
|
6
|
+
const dir = path.join(os.homedir(), ".config", "tdm");
|
|
7
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8
|
+
return dir;
|
|
9
|
+
}
|
|
10
|
+
export function configPath() {
|
|
11
|
+
return path.join(configDir(), "config.json");
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
const p = configPath();
|
|
15
|
+
if (!fs.existsSync(p)) {
|
|
16
|
+
return DEFAULT_CONFIG;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
20
|
+
return ConfigSchema.parse(JSON.parse(raw));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return DEFAULT_CONFIG;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function saveConfig(config) {
|
|
27
|
+
const p = configPath();
|
|
28
|
+
fs.writeFileSync(p, JSON.stringify(ConfigSchema.parse(config), null, 2), { mode: 0o600 });
|
|
29
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { GQL_OPERATIONS } from "../integrations/gqlOperations.js";
|
|
2
|
+
import { gqlRequest } from "../integrations/gqlClient.js";
|
|
3
|
+
import { sortChannelCandidates, canWatchChannel } from "../domain/channel.js";
|
|
4
|
+
import { logger } from "./runtime.js";
|
|
5
|
+
export const MAX_CHANNELS = 100;
|
|
6
|
+
/**
|
|
7
|
+
* Parse Twitch GQL DirectoryPage_Game response into Channel list.
|
|
8
|
+
* Expects structure: data.game.streams.edges[].node with broadcaster(s), viewersCount, game.
|
|
9
|
+
*/
|
|
10
|
+
export function parseGameDirectoryResponse(response, gameName, aclBased) {
|
|
11
|
+
const data = response?.data;
|
|
12
|
+
const game = data?.game;
|
|
13
|
+
const streams = game?.streams;
|
|
14
|
+
const edges = streams?.edges ?? [];
|
|
15
|
+
const channels = [];
|
|
16
|
+
for (const edge of edges) {
|
|
17
|
+
const node = edge?.node;
|
|
18
|
+
if (!node)
|
|
19
|
+
continue;
|
|
20
|
+
let broadcasters = Array.isArray(node.broadcasters) ? node.broadcasters : [];
|
|
21
|
+
if (broadcasters.length === 0 && node.broadcaster != null) {
|
|
22
|
+
broadcasters = [node.broadcaster];
|
|
23
|
+
}
|
|
24
|
+
const broadcaster = broadcasters[0];
|
|
25
|
+
if (!broadcaster)
|
|
26
|
+
continue;
|
|
27
|
+
const id = String(broadcaster.id ?? broadcaster.login ?? "");
|
|
28
|
+
const login = String(broadcaster.login ?? broadcaster.displayName ?? id);
|
|
29
|
+
if (!id || !login)
|
|
30
|
+
continue;
|
|
31
|
+
const viewers = Number(node.viewersCount ?? node.viewerCount ?? 0);
|
|
32
|
+
const gameNode = node.game;
|
|
33
|
+
const displayGame = gameNode
|
|
34
|
+
? String(gameNode.displayName ?? gameNode.name ?? gameName)
|
|
35
|
+
: gameName;
|
|
36
|
+
const streamId = node.id != null ? String(node.id) : undefined;
|
|
37
|
+
channels.push({
|
|
38
|
+
id,
|
|
39
|
+
login,
|
|
40
|
+
online: true,
|
|
41
|
+
viewers: Number.isFinite(viewers) ? viewers : 0,
|
|
42
|
+
gameName: displayGame,
|
|
43
|
+
dropsEnabled: node.isDropsEnabled === false ? false : true,
|
|
44
|
+
aclBased,
|
|
45
|
+
streamId
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return channels;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build ACL-based channel IDs from campaigns (campaign allowlist / tags if present in GQL).
|
|
52
|
+
* For now campaigns do not expose channel allowlist in our GQL shape; return empty set.
|
|
53
|
+
*/
|
|
54
|
+
export function getAclChannelIdsFromCampaigns(_campaigns) {
|
|
55
|
+
const ids = new Set();
|
|
56
|
+
// When GQL provides campaign channel allowlist, add those ids here.
|
|
57
|
+
return ids;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fetch channels for wanted games via GameDirectory GQL, merge and cap to maxChannels.
|
|
61
|
+
* ACL channels (from campaign allowlist) are marked and preferred in sorting elsewhere.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Resolve game name to Twitch directory slug (from campaigns when available).
|
|
65
|
+
*/
|
|
66
|
+
function gameNameToSlug(gameName, campaigns) {
|
|
67
|
+
const c = campaigns.find((camp) => camp.gameName === gameName);
|
|
68
|
+
return c ? c.gameSlug : gameName.toLowerCase().replace(/\s+/g, "-");
|
|
69
|
+
}
|
|
70
|
+
export async function fetchChannelsForWantedGames(token, options) {
|
|
71
|
+
const { wantedGames, campaigns, maxChannels = MAX_CHANNELS } = options;
|
|
72
|
+
const aclIds = getAclChannelIdsFromCampaigns(campaigns);
|
|
73
|
+
const byId = new Map();
|
|
74
|
+
for (const gameName of wantedGames) {
|
|
75
|
+
const slug = gameNameToSlug(gameName, campaigns);
|
|
76
|
+
const response = await gqlRequest(GQL_OPERATIONS.GameDirectory, token, {
|
|
77
|
+
slug,
|
|
78
|
+
limit: 30,
|
|
79
|
+
sortTypeIsRecency: false,
|
|
80
|
+
includeCostreaming: false
|
|
81
|
+
});
|
|
82
|
+
const resp = response;
|
|
83
|
+
const gqlErrors = resp?.errors;
|
|
84
|
+
if (gqlErrors?.length) {
|
|
85
|
+
logger.warn({ gameName, slug, gqlErrors }, "GameDirectory GQL errors");
|
|
86
|
+
}
|
|
87
|
+
const aclBased = false;
|
|
88
|
+
const list = parseGameDirectoryResponse(response, gameName, aclBased);
|
|
89
|
+
for (const ch of list) {
|
|
90
|
+
const existing = byId.get(ch.id);
|
|
91
|
+
const acl = aclIds.has(ch.id);
|
|
92
|
+
const merged = {
|
|
93
|
+
...ch,
|
|
94
|
+
aclBased: existing?.aclBased ?? acl
|
|
95
|
+
};
|
|
96
|
+
if (!existing || merged.viewers > existing.viewers) {
|
|
97
|
+
byId.set(ch.id, merged);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let result = Array.from(byId.values());
|
|
102
|
+
result = result.filter((ch) => canWatchChannel(ch, wantedGames));
|
|
103
|
+
result = sortChannelCandidates(result, wantedGames);
|
|
104
|
+
return result.slice(0, maxChannels);
|
|
105
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const TWITCH_OAUTH_DEVICE_URL = "https://id.twitch.tv/oauth2/device";
|
|
2
|
+
export const TWITCH_OAUTH_TOKEN_URL = "https://id.twitch.tv/oauth2/token";
|
|
3
|
+
export const TWITCH_OAUTH_VALIDATE_URL = "https://id.twitch.tv/oauth2/validate";
|
|
4
|
+
export const TWITCH_GQL_URL = "https://gql.twitch.tv/gql";
|
|
5
|
+
export const TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv/v1";
|
|
6
|
+
export const WATCH_INTERVAL_MS = 59_000;
|
|
7
|
+
export const PING_INTERVAL_MS = 180_000;
|
|
8
|
+
export const PING_TIMEOUT_MS = 10_000;
|
|
9
|
+
export const MAX_WEBSOCKETS = 8;
|
|
10
|
+
export const WS_TOPICS_LIMIT = 50;
|
|
11
|
+
export const TWITCH_ANDROID_CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp";
|
|
12
|
+
export const TWITCH_ANDROID_USER_AGENT = "Dalvik/2.1.0 (Linux; Android 16; SM-S911B Build/TP1A.220624.014) tv.twitch.android.app/25.3.0/2503006";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class MaintenanceScheduler {
|
|
2
|
+
timer = null;
|
|
3
|
+
start(intervalMs, callback) {
|
|
4
|
+
this.stop();
|
|
5
|
+
this.timer = setInterval(() => {
|
|
6
|
+
void callback();
|
|
7
|
+
}, intervalMs);
|
|
8
|
+
}
|
|
9
|
+
stop() {
|
|
10
|
+
if (this.timer) {
|
|
11
|
+
clearInterval(this.timer);
|
|
12
|
+
this.timer = null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|