twitchdropsminer-cli 0.1.4 → 0.2.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.
@@ -1,5 +1,20 @@
1
1
  import { Command } from "@commander-js/extra-typings";
2
2
  import { loadSessionState } from "../../state/sessionState.js";
3
+ import { isMinerLockHeldByLiveProcess } from "../../core/runtime.js";
4
+ const SESSION_FRESH_MS = 120_000;
5
+ function sessionImpliesRunning(rawState, updatedAt) {
6
+ if (rawState === "EXIT" || rawState === "UNKNOWN") {
7
+ return false;
8
+ }
9
+ if (!updatedAt) {
10
+ return false;
11
+ }
12
+ const t = new Date(updatedAt).getTime();
13
+ if (!Number.isFinite(t)) {
14
+ return false;
15
+ }
16
+ return Date.now() - t < SESSION_FRESH_MS;
17
+ }
3
18
  export const statusCommand = new Command("status")
4
19
  .description("Show current miner status")
5
20
  .option("--json", "Output status as JSON")
@@ -11,8 +26,11 @@ export const statusCommand = new Command("status")
11
26
  : rawState !== "IDLE" && rawState !== "EXIT"
12
27
  ? "MAINTENANCE"
13
28
  : rawState;
29
+ const lockHeld = isMinerLockHeldByLiveProcess();
30
+ const running = lockHeld || sessionImpliesRunning(rawState, session?.updatedAt);
14
31
  const status = {
15
- running: rawState !== "EXIT",
32
+ running,
33
+ lockHeld,
16
34
  state: highLevel,
17
35
  rawState,
18
36
  watchedChannel: session?.watchedChannelName ?? null,
@@ -24,6 +42,6 @@ export const statusCommand = new Command("status")
24
42
  }
25
43
  else {
26
44
  // eslint-disable-next-line no-console
27
- console.log(`State=${status.state}, channel=${status.watchedChannel ?? "-"}, activeDrop=${status.activeDrop ?? "-"}`);
45
+ console.log(`Running=${status.running}, lock=${status.lockHeld}, state=${status.state}, channel=${status.watchedChannel ?? "-"}, activeDrop=${status.activeDrop ?? "-"}`);
28
46
  }
29
47
  });
@@ -11,6 +11,10 @@ export const ConfigSchema = z.object({
11
11
  trayNotifications: z.boolean().default(true),
12
12
  enableBadgesEmotes: z.boolean().default(false),
13
13
  availableDropsCheck: z.boolean().default(false),
14
- priorityMode: PriorityModeSchema.default("priority_only")
14
+ priorityMode: PriorityModeSchema.default("priority_only"),
15
+ /** Max parallel GameDirectory GQL fetches when resolving channels (default 4). */
16
+ channelFetchConcurrency: z.number().int().min(1).max(10).default(4),
17
+ /** Override persisted-query sha256 hashes when Twitch rotates them (operationName -> hash). */
18
+ gqlHashOverrides: z.record(z.string(), z.string()).default({})
15
19
  });
16
20
  export const DEFAULT_CONFIG = ConfigSchema.parse({});
@@ -2,6 +2,8 @@ import { GQL_OPERATIONS } from "../integrations/gqlOperations.js";
2
2
  import { gqlRequest } from "../integrations/gqlClient.js";
3
3
  import { sortChannelCandidates, canWatchChannel } from "../domain/channel.js";
4
4
  import { logger } from "./runtime.js";
5
+ import { mapWithConcurrency } from "./concurrency.js";
6
+ import { loadConfig } from "../config/store.js";
5
7
  export const MAX_CHANNELS = 100;
6
8
  /**
7
9
  * Parse Twitch GQL DirectoryPage_Game response into Channel list.
@@ -56,10 +58,6 @@ export function getAclChannelIdsFromCampaigns(_campaigns) {
56
58
  // When GQL provides campaign channel allowlist, add those ids here.
57
59
  return ids;
58
60
  }
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
61
  /**
64
62
  * Resolve game name to Twitch directory slug (from campaigns when available).
65
63
  */
@@ -69,16 +67,22 @@ function gameNameToSlug(gameName, campaigns) {
69
67
  }
70
68
  export async function fetchChannelsForWantedGames(token, options) {
71
69
  const { wantedGames, campaigns, maxChannels = MAX_CHANNELS } = options;
70
+ const gql = options.gqlRequestImpl ??
71
+ ((op, t, v) => gqlRequest(op, t, v));
72
+ const concurrency = options.fetchConcurrency ?? loadConfig().channelFetchConcurrency;
72
73
  const aclIds = getAclChannelIdsFromCampaigns(campaigns);
73
74
  const byId = new Map();
74
- for (const gameName of wantedGames) {
75
+ const rows = await mapWithConcurrency(wantedGames, concurrency, async (gameName) => {
75
76
  const slug = gameNameToSlug(gameName, campaigns);
76
- const response = await gqlRequest(GQL_OPERATIONS.GameDirectory, token, {
77
+ const response = await gql(GQL_OPERATIONS.GameDirectory, token, {
77
78
  slug,
78
79
  limit: 30,
79
80
  sortTypeIsRecency: false,
80
81
  includeCostreaming: false
81
82
  });
83
+ return { gameName, slug, response };
84
+ });
85
+ for (const { gameName, slug, response } of rows) {
82
86
  const resp = response;
83
87
  const gqlErrors = resp?.errors;
84
88
  if (gqlErrors?.length) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Map items to promises with at most `concurrency` in flight (results align with input order).
3
+ */
4
+ export async function mapWithConcurrency(items, concurrency, mapper) {
5
+ if (items.length === 0) {
6
+ return [];
7
+ }
8
+ const limit = Math.max(1, Math.min(concurrency, items.length));
9
+ const results = new Array(items.length);
10
+ let next = 0;
11
+ async function worker() {
12
+ while (true) {
13
+ const i = next++;
14
+ if (i >= items.length) {
15
+ return;
16
+ }
17
+ results[i] = await mapper(items[i], i);
18
+ }
19
+ }
20
+ await Promise.all(Array.from({ length: limit }, () => worker()));
21
+ return results;
22
+ }
@@ -29,6 +29,10 @@ export class Miner {
29
29
  spadeUrlCache = new Map();
30
30
  pubsub = null;
31
31
  dryRun = false;
32
+ signalHandlersAttached = false;
33
+ onShutdownSignal = () => {
34
+ void this.shutdown();
35
+ };
32
36
  async run(options) {
33
37
  if (this.running) {
34
38
  return;
@@ -96,14 +100,10 @@ export class Miner {
96
100
  this.state.setState("CHANNELS_CLEANUP");
97
101
  }
98
102
  });
99
- process.on("SIGINT", () => {
100
- void this.shutdown();
101
- });
102
- process.on("SIGTERM", () => {
103
- void this.shutdown();
104
- });
103
+ this.attachSignalHandlers();
105
104
  }
106
105
  async shutdown() {
106
+ this.detachSignalHandlers();
107
107
  this.running = false;
108
108
  this.watchLoop.stop();
109
109
  this.maintenance.stop();
@@ -120,6 +120,22 @@ export class Miner {
120
120
  watchedChannelName: this.watchingChannel?.login
121
121
  });
122
122
  }
123
+ attachSignalHandlers() {
124
+ if (this.signalHandlersAttached) {
125
+ return;
126
+ }
127
+ this.signalHandlersAttached = true;
128
+ process.on("SIGINT", this.onShutdownSignal);
129
+ process.on("SIGTERM", this.onShutdownSignal);
130
+ }
131
+ detachSignalHandlers() {
132
+ if (!this.signalHandlersAttached) {
133
+ return;
134
+ }
135
+ process.off("SIGINT", this.onShutdownSignal);
136
+ process.off("SIGTERM", this.onShutdownSignal);
137
+ this.signalHandlersAttached = false;
138
+ }
123
139
  async claimEligibleDrops(token) {
124
140
  for (const campaign of this.campaigns) {
125
141
  for (const drop of campaign.drops) {
@@ -6,13 +6,37 @@ export const logger = pino({
6
6
  level: process.env.TDM_LOG_LEVEL || "info"
7
7
  });
8
8
  let lockFd = null;
9
- function lockPath() {
9
+ export function minerLockPath() {
10
10
  const dir = path.join(os.homedir(), ".local", "state", "tdm");
11
11
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
12
12
  return path.join(dir, "lock.file");
13
13
  }
14
+ /** True if lock file exists and the recorded PID is still running (best-effort). */
15
+ export function isMinerLockHeldByLiveProcess() {
16
+ const p = minerLockPath();
17
+ if (!fs.existsSync(p)) {
18
+ return false;
19
+ }
20
+ try {
21
+ const raw = fs.readFileSync(p, "utf8").trim();
22
+ const pid = Number(raw);
23
+ if (!Number.isFinite(pid) || pid <= 0) {
24
+ return true;
25
+ }
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
14
38
  export function ensureSingleInstanceLock() {
15
- const p = lockPath();
39
+ const p = minerLockPath();
16
40
  try {
17
41
  lockFd = fs.openSync(p, "wx", 0o600);
18
42
  fs.writeFileSync(lockFd, String(process.pid));
@@ -1,8 +1,46 @@
1
1
  import { httpJson } from "./httpClient.js";
2
2
  import { TWITCH_GQL_URL, TWITCH_ANDROID_CLIENT_ID, TWITCH_ANDROID_USER_AGENT } from "../core/constants.js";
3
- import { gqlPayload } from "./gqlOperations.js";
3
+ import { gqlPayload, applyGqlHashOverride } from "./gqlOperations.js";
4
+ import { loadConfig } from "../config/store.js";
5
+ export class GqlPersistedQueryMismatchError extends Error {
6
+ operationName;
7
+ sha256Hash;
8
+ gqlMessages;
9
+ constructor(operationName, sha256Hash, gqlMessages) {
10
+ super(`Twitch GQL persisted query failed for "${operationName}" (sha256=${sha256Hash}). ` +
11
+ `Set "gqlHashOverrides" in config (see tdm config path) with { "${operationName}": "<new_hash>" }. ` +
12
+ `GQL: ${gqlMessages.slice(0, 400)}`);
13
+ this.name = "GqlPersistedQueryMismatchError";
14
+ this.operationName = operationName;
15
+ this.sha256Hash = sha256Hash;
16
+ this.gqlMessages = gqlMessages;
17
+ }
18
+ }
19
+ function collectGqlErrorText(payload) {
20
+ const rec = payload;
21
+ if (!rec.errors?.length) {
22
+ return "";
23
+ }
24
+ return rec.errors
25
+ .map((e) => {
26
+ const ext = e.extensions ? JSON.stringify(e.extensions) : "";
27
+ return `${e.message ?? ""} ${ext}`;
28
+ })
29
+ .join(" | ");
30
+ }
31
+ export function assertNoGqlPersistedQueryFailure(operation, payload) {
32
+ const text = collectGqlErrorText(payload);
33
+ if (!text) {
34
+ return;
35
+ }
36
+ if (/PersistedQueryNotFound|NotFoundForSha256|persisted query|does not match|Unknown query/i.test(text)) {
37
+ throw new GqlPersistedQueryMismatchError(operation.operationName, operation.sha256Hash, text);
38
+ }
39
+ }
4
40
  export async function gqlRequest(operation, accessToken, variables) {
5
- return httpJson("POST", TWITCH_GQL_URL, gqlPayload(operation, variables), {
41
+ const cfg = loadConfig();
42
+ const resolved = applyGqlHashOverride(operation, cfg.gqlHashOverrides);
43
+ const payload = await httpJson("POST", TWITCH_GQL_URL, gqlPayload(resolved, variables), {
6
44
  retries: 3,
7
45
  headers: {
8
46
  "Client-Id": TWITCH_ANDROID_CLIENT_ID,
@@ -10,4 +48,6 @@ export async function gqlRequest(operation, accessToken, variables) {
10
48
  Authorization: `OAuth ${accessToken}`
11
49
  }
12
50
  });
51
+ assertNoGqlPersistedQueryFailure(resolved, payload);
52
+ return payload;
13
53
  }
@@ -40,3 +40,11 @@ export function gqlPayload(operation, variables) {
40
40
  }
41
41
  };
42
42
  }
43
+ /** Apply config overrides for persisted-query hashes (keyed by operationName). */
44
+ export function applyGqlHashOverride(operation, overrides) {
45
+ const h = overrides[operation.operationName];
46
+ if (!h) {
47
+ return operation;
48
+ }
49
+ return { ...operation, sha256Hash: h };
50
+ }
@@ -1,36 +1,109 @@
1
1
  import { request } from "undici";
2
+ const DEFAULT_TIMEOUT_MS = 30_000;
2
3
  function sleep(ms) {
3
4
  return new Promise((resolve) => setTimeout(resolve, ms));
4
5
  }
6
+ function jitterMs(base) {
7
+ return base + Math.floor(Math.random() * 0.25 * base);
8
+ }
9
+ function backoffMs(attempt, baseDelayMs) {
10
+ const exp = baseDelayMs * 2 ** Math.min(attempt, 8);
11
+ const capped = Math.min(60_000, exp);
12
+ return jitterMs(capped);
13
+ }
14
+ /** Parse Twitch/HTTP Retry-After header value: seconds or HTTP-date. */
15
+ export function parseRetryAfterMsFromValue(raw) {
16
+ if (!raw) {
17
+ return null;
18
+ }
19
+ const trimmed = raw.trim();
20
+ const asNum = Number(trimmed);
21
+ if (!Number.isNaN(asNum) && asNum >= 0) {
22
+ return asNum * 1000;
23
+ }
24
+ const when = Date.parse(trimmed);
25
+ if (!Number.isNaN(when)) {
26
+ return Math.max(0, when - Date.now());
27
+ }
28
+ return null;
29
+ }
30
+ /** Parse Retry-After from Fetch-style headers. */
31
+ export function parseRetryAfterMs(headers) {
32
+ return parseRetryAfterMsFromValue(headers.get("retry-after"));
33
+ }
34
+ function retryAfterFromUndiciHeaders(headers) {
35
+ const h = headers;
36
+ if (typeof h.get === "function") {
37
+ return parseRetryAfterMsFromValue(h.get("retry-after"));
38
+ }
39
+ return null;
40
+ }
41
+ function isAbortError(err) {
42
+ return (err instanceof Error &&
43
+ (err.name === "AbortError" || err.message.includes("aborted") || err.message.includes("timeout")));
44
+ }
45
+ export class HttpResponseError extends Error {
46
+ statusCode;
47
+ bodySnippet;
48
+ constructor(statusCode, bodySnippet) {
49
+ super(`HTTP ${statusCode}: ${bodySnippet.slice(0, 500)}`);
50
+ this.name = "HttpResponseError";
51
+ this.statusCode = statusCode;
52
+ this.bodySnippet = bodySnippet;
53
+ }
54
+ }
5
55
  export async function httpJson(method, url, body, options) {
6
56
  const retries = options?.retries ?? 3;
7
57
  const retryDelayMs = options?.retryDelayMs ?? 1_000;
58
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
8
59
  let lastError;
9
60
  for (let attempt = 0; attempt <= retries; attempt += 1) {
10
61
  try {
62
+ const signal = AbortSignal.timeout(timeoutMs);
11
63
  const response = await request(url, {
12
64
  method: method,
13
65
  headers: {
14
66
  "content-type": "application/json",
15
67
  ...(options?.headers ?? {})
16
68
  },
17
- body: body !== undefined ? JSON.stringify(body) : undefined
69
+ body: body !== undefined ? JSON.stringify(body) : undefined,
70
+ signal
18
71
  });
19
72
  const text = await response.body.text();
20
- if (response.statusCode >= 500) {
21
- throw new Error(`HTTP ${response.statusCode}: ${text}`);
73
+ if (response.statusCode >= 200 && response.statusCode < 300) {
74
+ if (!text) {
75
+ return {};
76
+ }
77
+ return JSON.parse(text);
22
78
  }
23
- if (!text) {
24
- return {};
79
+ if (response.statusCode === 429 || response.statusCode >= 500) {
80
+ lastError = new HttpResponseError(response.statusCode, text);
81
+ if (attempt === retries) {
82
+ break;
83
+ }
84
+ const fromHeader = retryAfterFromUndiciHeaders(response.headers);
85
+ const waitMs = fromHeader !== null ? fromHeader : backoffMs(attempt, retryDelayMs);
86
+ await sleep(waitMs);
87
+ continue;
25
88
  }
26
- return JSON.parse(text);
89
+ throw new HttpResponseError(response.statusCode, text);
27
90
  }
28
91
  catch (err) {
29
92
  lastError = err;
93
+ if (err instanceof HttpResponseError) {
94
+ throw err;
95
+ }
30
96
  if (attempt === retries) {
31
97
  break;
32
98
  }
33
- await sleep(retryDelayMs * (attempt + 1));
99
+ const retryable = isAbortError(err) ||
100
+ err instanceof TypeError ||
101
+ (err instanceof Error &&
102
+ /ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket/i.test(err.message));
103
+ if (!retryable) {
104
+ throw err;
105
+ }
106
+ await sleep(backoffMs(attempt, retryDelayMs));
34
107
  }
35
108
  }
36
109
  throw lastError instanceof Error ? lastError : new Error("HTTP request failed.");
@@ -1,29 +1,40 @@
1
1
  import WebSocket from "ws";
2
- import { PING_INTERVAL_MS, TWITCH_PUBSUB_URL, WS_TOPICS_LIMIT } from "../core/constants.js";
2
+ import { PING_INTERVAL_MS, PING_TIMEOUT_MS, TWITCH_PUBSUB_URL, WS_TOPICS_LIMIT } from "../core/constants.js";
3
+ const RECONNECT_BASE_MS = 1_000;
4
+ const RECONNECT_MAX_MS = 60_000;
5
+ function reconnectDelayMs(attempt) {
6
+ const exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(attempt, 10));
7
+ const jitter = Math.floor(Math.random() * 0.25 * exp);
8
+ return exp + jitter;
9
+ }
3
10
  export class TwitchPubSub {
4
11
  ws = null;
5
12
  pingTimer = null;
13
+ pongWatchTimer = null;
14
+ reconnectTimer = null;
6
15
  handlers = new Map();
7
16
  subscribedTopics = new Set();
8
17
  authToken = null;
18
+ stopped = false;
19
+ reconnectAttempt = 0;
20
+ createWs;
21
+ constructor(options) {
22
+ this.createWs = options?.createWebSocket ?? ((url) => new WebSocket(url));
23
+ }
9
24
  async start() {
25
+ if (this.stopped) {
26
+ return;
27
+ }
10
28
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
11
29
  return;
12
30
  }
13
- await new Promise((resolve, reject) => {
14
- const ws = new WebSocket(TWITCH_PUBSUB_URL);
15
- this.ws = ws;
16
- ws.on("open", () => {
17
- this.startPing();
18
- resolve();
19
- });
20
- ws.on("error", (err) => reject(err));
21
- ws.on("message", (data) => this.onMessage(data.toString()));
22
- ws.on("close", () => this.stopPing());
23
- });
31
+ await this.connectOnce();
24
32
  }
25
33
  async stop() {
34
+ this.stopped = true;
35
+ this.clearReconnectTimer();
26
36
  this.stopPing();
37
+ this.clearPongWatch();
27
38
  this.subscribedTopics.clear();
28
39
  this.authToken = null;
29
40
  const ws = this.ws;
@@ -41,9 +52,6 @@ export class TwitchPubSub {
41
52
  }
42
53
  /** Subscribe to topics; batches and enforces WS_TOPICS_LIMIT. Stores token for reconnect. */
43
54
  listen(topics, authToken) {
44
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
45
- throw new Error("PubSub socket is not connected.");
46
- }
47
55
  this.authToken = authToken;
48
56
  const toAdd = topics.filter((t) => !this.subscribedTopics.has(t));
49
57
  if (toAdd.length === 0)
@@ -55,22 +63,19 @@ export class TwitchPubSub {
55
63
  for (const t of batch) {
56
64
  this.subscribedTopics.add(t);
57
65
  }
58
- this.ws.send(JSON.stringify({
59
- type: "LISTEN",
60
- data: { topics: batch, auth_token: authToken }
61
- }));
66
+ this.sendListenBatch(batch, authToken);
62
67
  }
63
68
  /** Unsubscribe from topics and send UNLISTEN. */
64
69
  unlisten(topics, authToken) {
65
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
66
- return;
67
- }
68
70
  const toRemove = topics.filter((t) => this.subscribedTopics.has(t));
69
71
  if (toRemove.length === 0)
70
72
  return;
71
73
  for (const t of toRemove) {
72
74
  this.subscribedTopics.delete(t);
73
75
  }
76
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
77
+ return;
78
+ }
74
79
  this.ws.send(JSON.stringify({
75
80
  type: "UNLISTEN",
76
81
  data: { topics: toRemove, auth_token: authToken }
@@ -79,10 +84,89 @@ export class TwitchPubSub {
79
84
  getSubscribedTopics() {
80
85
  return Array.from(this.subscribedTopics);
81
86
  }
87
+ clearReconnectTimer() {
88
+ if (this.reconnectTimer) {
89
+ clearTimeout(this.reconnectTimer);
90
+ this.reconnectTimer = null;
91
+ }
92
+ }
93
+ scheduleReconnect() {
94
+ if (this.stopped || this.reconnectTimer) {
95
+ return;
96
+ }
97
+ const delay = reconnectDelayMs(this.reconnectAttempt);
98
+ this.reconnectAttempt += 1;
99
+ this.reconnectTimer = setTimeout(() => {
100
+ this.reconnectTimer = null;
101
+ void this.connectOnce().catch(() => {
102
+ if (!this.stopped) {
103
+ this.scheduleReconnect();
104
+ }
105
+ });
106
+ }, delay);
107
+ }
108
+ async connectOnce() {
109
+ if (this.stopped) {
110
+ return;
111
+ }
112
+ return new Promise((resolve, reject) => {
113
+ const ws = this.createWs(TWITCH_PUBSUB_URL);
114
+ this.ws = ws;
115
+ let opened = false;
116
+ const onOpen = () => {
117
+ opened = true;
118
+ this.reconnectAttempt = 0;
119
+ this.resubscribeAll();
120
+ this.startPing();
121
+ ws.off("error", onError);
122
+ resolve();
123
+ };
124
+ const onError = (err) => {
125
+ ws.off("open", onOpen);
126
+ reject(err);
127
+ };
128
+ ws.once("open", onOpen);
129
+ ws.once("error", onError);
130
+ ws.on("message", (data) => {
131
+ void this.onMessage(data.toString());
132
+ });
133
+ ws.on("close", () => {
134
+ this.stopPing();
135
+ this.clearPongWatch();
136
+ const wasCurrent = this.ws === ws;
137
+ if (wasCurrent) {
138
+ this.ws = null;
139
+ }
140
+ if (!this.stopped && wasCurrent && opened) {
141
+ this.scheduleReconnect();
142
+ }
143
+ });
144
+ });
145
+ }
146
+ resubscribeAll() {
147
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authToken) {
148
+ return;
149
+ }
150
+ const topics = Array.from(this.subscribedTopics);
151
+ for (let i = 0; i < topics.length; i += WS_TOPICS_LIMIT) {
152
+ const batch = topics.slice(i, i + WS_TOPICS_LIMIT);
153
+ this.sendListenBatch(batch, this.authToken);
154
+ }
155
+ }
156
+ sendListenBatch(batch, authToken) {
157
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
158
+ return;
159
+ }
160
+ this.ws.send(JSON.stringify({
161
+ type: "LISTEN",
162
+ data: { topics: batch, auth_token: authToken }
163
+ }));
164
+ }
82
165
  startPing() {
83
166
  this.stopPing();
84
167
  this.pingTimer = setInterval(() => {
85
168
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
169
+ this.armPongWatch();
86
170
  this.ws.send(JSON.stringify({ type: "PING" }));
87
171
  }
88
172
  }, PING_INTERVAL_MS);
@@ -93,6 +177,27 @@ export class TwitchPubSub {
93
177
  this.pingTimer = null;
94
178
  }
95
179
  }
180
+ armPongWatch() {
181
+ this.clearPongWatch();
182
+ this.pongWatchTimer = setTimeout(() => {
183
+ this.pongWatchTimer = null;
184
+ const ws = this.ws;
185
+ if (ws && ws.readyState === WebSocket.OPEN) {
186
+ try {
187
+ ws.terminate();
188
+ }
189
+ catch {
190
+ // ignore
191
+ }
192
+ }
193
+ }, PING_TIMEOUT_MS);
194
+ }
195
+ clearPongWatch() {
196
+ if (this.pongWatchTimer) {
197
+ clearTimeout(this.pongWatchTimer);
198
+ this.pongWatchTimer = null;
199
+ }
200
+ }
96
201
  async onMessage(raw) {
97
202
  let parsed;
98
203
  try {
@@ -102,6 +207,7 @@ export class TwitchPubSub {
102
207
  return;
103
208
  }
104
209
  if (parsed.type === "PONG") {
210
+ this.clearPongWatch();
105
211
  return;
106
212
  }
107
213
  if (parsed.type === "MESSAGE" && typeof parsed.data === "object" && parsed.data) {
@@ -1,7 +1,12 @@
1
1
  import "./unit/tokenImport.test.js";
2
2
  import "./unit/channel.test.js";
3
3
  import "./unit/channelService.test.js";
4
+ import "./unit/channelServiceConcurrency.test.js";
5
+ import "./unit/concurrency.test.js";
4
6
  import "./unit/dropsDomain.test.js";
7
+ import "./unit/gqlClient.test.js";
8
+ import "./unit/httpClient.test.js";
9
+ import "./unit/twitchPubSub.test.js";
5
10
  import "./unit/twitchSpade.test.js";
6
11
  import "./integration/configStore.test.js";
7
12
  import "./parity/stateMachineFlow.test.js";
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fetchChannelsForWantedGames } from "../../core/channelService.js";
4
+ import { GQL_OPERATIONS } from "../../integrations/gqlOperations.js";
5
+ test("fetchChannelsForWantedGames respects fetchConcurrency", async () => {
6
+ let maxParallel = 0;
7
+ let current = 0;
8
+ const games = ["A", "B", "C", "D", "E", "F"];
9
+ await fetchChannelsForWantedGames("fake-token", {
10
+ wantedGames: games,
11
+ campaigns: [],
12
+ fetchConcurrency: 2,
13
+ gqlRequestImpl: async (_op, _token, _vars) => {
14
+ current += 1;
15
+ maxParallel = Math.max(maxParallel, current);
16
+ await new Promise((r) => setTimeout(r, 5));
17
+ current -= 1;
18
+ return { data: { game: { streams: { edges: [] } } } };
19
+ }
20
+ });
21
+ assert.equal(maxParallel, 2);
22
+ });
23
+ test("fetchChannelsForWantedGames uses GameDirectory operation", async () => {
24
+ let seenOp;
25
+ await fetchChannelsForWantedGames("t", {
26
+ wantedGames: ["X"],
27
+ campaigns: [],
28
+ fetchConcurrency: 1,
29
+ gqlRequestImpl: async (op) => {
30
+ seenOp = op;
31
+ return { data: { game: { streams: { edges: [] } } } };
32
+ }
33
+ });
34
+ assert.equal(seenOp.operationName, GQL_OPERATIONS.GameDirectory.operationName);
35
+ });
@@ -0,0 +1,21 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mapWithConcurrency } from "../../core/concurrency.js";
4
+ test("mapWithConcurrency limits parallel execution", async () => {
5
+ let inFlight = 0;
6
+ let maxInFlight = 0;
7
+ const items = [1, 2, 3, 4, 5, 6];
8
+ const results = await mapWithConcurrency(items, 2, async (n) => {
9
+ inFlight += 1;
10
+ maxInFlight = Math.max(maxInFlight, inFlight);
11
+ await new Promise((r) => setTimeout(r, 5));
12
+ inFlight -= 1;
13
+ return n * 2;
14
+ });
15
+ assert.deepEqual(results, [2, 4, 6, 8, 10, 12]);
16
+ assert.equal(maxInFlight, 2);
17
+ });
18
+ test("mapWithConcurrency preserves order", async () => {
19
+ const out = await mapWithConcurrency(["a", "b", "c"], 3, async (x) => `${x}1`);
20
+ assert.deepEqual(out, ["a1", "b1", "c1"]);
21
+ });
@@ -0,0 +1,14 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { assertNoGqlPersistedQueryFailure, GqlPersistedQueryMismatchError } from "../../integrations/gqlClient.js";
4
+ import { GQL_OPERATIONS } from "../../integrations/gqlOperations.js";
5
+ test("assertNoGqlPersistedQueryFailure ignores empty errors", () => {
6
+ assertNoGqlPersistedQueryFailure(GQL_OPERATIONS.Inventory, { data: {} });
7
+ });
8
+ test("assertNoGqlPersistedQueryFailure throws on persisted query mismatch", () => {
9
+ assert.throws(() => assertNoGqlPersistedQueryFailure(GQL_OPERATIONS.Inventory, {
10
+ errors: [{ message: "PersistedQueryNotFound" }]
11
+ }), (e) => e instanceof GqlPersistedQueryMismatchError &&
12
+ e.operationName === "Inventory" &&
13
+ e.sha256Hash === GQL_OPERATIONS.Inventory.sha256Hash);
14
+ });
@@ -0,0 +1,52 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createServer } from "node:http";
4
+ import { httpJson, parseRetryAfterMs, parseRetryAfterMsFromValue, HttpResponseError } from "../../integrations/httpClient.js";
5
+ test("parseRetryAfterMsFromValue parses delay-seconds", () => {
6
+ assert.equal(parseRetryAfterMsFromValue("5"), 5000);
7
+ assert.equal(parseRetryAfterMsFromValue("0"), 0);
8
+ });
9
+ test("parseRetryAfterMs reads header object", () => {
10
+ assert.equal(parseRetryAfterMs({ get: () => "2" }), 2000);
11
+ assert.equal(parseRetryAfterMs({ get: () => null }), null);
12
+ });
13
+ test("httpJson retries on 429 then succeeds", async () => {
14
+ let hits = 0;
15
+ const server = createServer((req, res) => {
16
+ hits += 1;
17
+ if (hits === 1) {
18
+ res.writeHead(429, { "Retry-After": "0" });
19
+ res.end("{}");
20
+ return;
21
+ }
22
+ res.writeHead(200, { "Content-Type": "application/json" });
23
+ res.end(JSON.stringify({ ok: true }));
24
+ });
25
+ await new Promise((resolve) => server.listen(0, resolve));
26
+ const { port } = server.address();
27
+ try {
28
+ const out = await httpJson("GET", `http://127.0.0.1:${port}/`, undefined, { retries: 2, retryDelayMs: 10, timeoutMs: 5000 });
29
+ assert.equal(out.ok, true);
30
+ assert.equal(hits, 2);
31
+ }
32
+ finally {
33
+ await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
34
+ }
35
+ });
36
+ test("httpJson fails fast on non-retryable 4xx", async () => {
37
+ const server = createServer((_req, res) => {
38
+ res.writeHead(404, { "Content-Type": "application/json" });
39
+ res.end(JSON.stringify({ error: "nope" }));
40
+ });
41
+ await new Promise((resolve) => server.listen(0, resolve));
42
+ const { port } = server.address();
43
+ try {
44
+ await assert.rejects(() => httpJson("GET", `http://127.0.0.1:${port}/`, undefined, {
45
+ retries: 3,
46
+ timeoutMs: 5000
47
+ }), (e) => e instanceof HttpResponseError && e.statusCode === 404);
48
+ }
49
+ finally {
50
+ await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
51
+ }
52
+ });
@@ -0,0 +1,63 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import WebSocket from "ws";
5
+ import { TwitchPubSub } from "../../integrations/twitchPubSub.js";
6
+ /** Minimal socket stub matching how TwitchPubSub uses `ws`. */
7
+ class FakeSocket extends EventEmitter {
8
+ static OPEN = WebSocket.OPEN;
9
+ readyState = WebSocket.CONNECTING;
10
+ sent = [];
11
+ send(data) {
12
+ this.sent.push(data);
13
+ }
14
+ openNow() {
15
+ this.readyState = WebSocket.OPEN;
16
+ this.emit("open");
17
+ }
18
+ close() {
19
+ this.readyState = WebSocket.CLOSED;
20
+ this.emit("close");
21
+ }
22
+ terminate() {
23
+ this.close();
24
+ }
25
+ }
26
+ test("TwitchPubSub listen sends LISTEN after open", async () => {
27
+ let sock;
28
+ const pubsub = new TwitchPubSub({
29
+ createWebSocket: () => {
30
+ sock = new FakeSocket();
31
+ queueMicrotask(() => sock.openNow());
32
+ return sock;
33
+ }
34
+ });
35
+ await pubsub.start();
36
+ pubsub.listen(["user-drop-events.99"], "tok");
37
+ const listenMsg = sock.sent.find((s) => s.includes('"LISTEN"'));
38
+ assert.ok(listenMsg);
39
+ assert.ok(listenMsg.includes("user-drop-events.99"));
40
+ assert.ok(listenMsg.includes("tok"));
41
+ await pubsub.stop();
42
+ });
43
+ test("TwitchPubSub reconnect resubscribes topics", async () => {
44
+ const sockets = [];
45
+ const pubsub = new TwitchPubSub({
46
+ createWebSocket: () => {
47
+ const s = new FakeSocket();
48
+ sockets.push(s);
49
+ queueMicrotask(() => s.openNow());
50
+ return s;
51
+ }
52
+ });
53
+ await pubsub.start();
54
+ pubsub.listen(["topic.one"], "token1");
55
+ const first = sockets[0];
56
+ assert.ok(first.sent.some((x) => x.includes("topic.one")));
57
+ first.close();
58
+ await new Promise((r) => setTimeout(r, 2500));
59
+ assert.ok(sockets.length >= 2, "expected second socket after reconnect");
60
+ const second = sockets[sockets.length - 1];
61
+ assert.ok(second.sent.some((x) => x.includes("topic.one")));
62
+ await pubsub.stop();
63
+ });
@@ -1,32 +1,32 @@
1
- ## Authentication (headless)
2
-
3
- ### Device-code login (recommended)
4
-
5
- ```bash
6
- tdm auth login --no-open
7
- ```
8
-
9
- Follow the printed `verification_uri` and `user_code` on another device.
10
-
11
- ### Import an existing token
12
-
13
- ```bash
14
- tdm auth import --token "auth-token=XXXX"
15
- # or
16
- tdm auth import --token-file /secure/path/token.txt
17
- ```
18
-
19
- ### Import cookies
20
-
21
- ```bash
22
- tdm auth import-cookie --cookie "auth-token=XXXX; other=YYY"
23
- # or
24
- tdm auth import-cookie --cookie-file /secure/path/cookies.txt
25
- ```
26
-
27
- ### Validate
28
-
29
- ```bash
30
- tdm auth validate
31
- ```
32
-
1
+ ## Authentication (headless)
2
+
3
+ ### Device-code login (recommended)
4
+
5
+ ```bash
6
+ tdm auth login --no-open
7
+ ```
8
+
9
+ Follow the printed `verification_uri` and `user_code` on another device.
10
+
11
+ ### Import an existing token
12
+
13
+ ```bash
14
+ tdm auth import --token "auth-token=XXXX"
15
+ # or
16
+ tdm auth import --token-file /secure/path/token.txt
17
+ ```
18
+
19
+ ### Import cookies
20
+
21
+ ```bash
22
+ tdm auth import-cookie --cookie "auth-token=XXXX; other=YYY"
23
+ # or
24
+ tdm auth import-cookie --cookie-file /secure/path/cookies.txt
25
+ ```
26
+
27
+ ### Validate
28
+
29
+ ```bash
30
+ tdm auth validate
31
+ ```
32
+
@@ -1,73 +1,73 @@
1
- # Drops validation playbook
2
-
3
- This playbook helps confirm that the CLI actually advances and claims Twitch Drops as intended, without opening a browser stream.
4
-
5
- ## Prerequisites
6
-
7
- - A Twitch account (test account recommended).
8
- - CLI installed and built: `npm run build`.
9
- - Auth completed: `tdm auth login --no-open` (or paste token when prompted).
10
-
11
- ## 1. Configure for a single game
12
-
13
- - Choose an active Drops campaign with a **short first drop** (e.g. 15–30 minutes) so you can see progress quickly.
14
- - In config (e.g. `~/.config/tdm/config.json` or project `tdm.config.json`), set:
15
- - `priority`: `["<Game Name>"]` (exact game name from the campaign).
16
- - `priorityMode`: `"priority_only"` so only that game is mined.
17
- - `exclude`: `[]` (or leave default).
18
-
19
- ## 2. Dry run (no network writes)
20
-
21
- - Run with dry-run and verbose to see intended actions only:
22
- ```bash
23
- tdm run --dry-run --verbose
24
- ```
25
- - Confirm logs show:
26
- - Inventory fetch and campaign list.
27
- - Wanted games = your priority game.
28
- - Channel fetch and selected channel.
29
- - “Would send watch” (no real spade POST).
30
- - “Would claim” for any claimable drop (no real ClaimDrop GQL).
31
- - Stop with Ctrl+C.
32
-
33
- ## 3. Live run and Twitch Inventory check
34
-
35
- - In a browser, open [Twitch Drops Inventory](https://www.twitch.tv/drops/inventory) and log in with the same account.
36
- - Note the current “minutes watched” (and “Claim” button if the drop is ready) for the target campaign.
37
- - Start the miner:
38
- ```bash
39
- tdm run --verbose
40
- ```
41
- - Let it run for at least one watch interval (about 1 minute). You should see “Watch tick sent for channel …” in the logs.
42
- - Refresh the Twitch Inventory page: “minutes watched” for the active drop should increase (may take 1–2 minutes to reflect).
43
- - If the drop becomes claimable, the CLI should auto-claim; check logs for “Claimed drop” and confirm the drop shows as claimed in the Inventory.
44
-
45
- ## 4. Compare with Python miner (optional)
46
-
47
- - Using the same Twitch account and same priority game:
48
- - Run the Python TwitchDropsMiner and note progression/claim time.
49
- - Run the CLI with the same config and note progression/claim time.
50
- - Progression and claim times should be comparable (allow for Twitch-side variance).
51
-
52
- ## 5. Status command
53
-
54
- - While the miner is running (or after it has run), in another terminal:
55
- ```bash
56
- tdm status
57
- tdm status --json
58
- ```
59
- - Confirm `state` (e.g. WATCHING or MAINTENANCE) and `activeDrop` (e.g. “Game Name: Drop Name”) look correct.
60
-
61
- ## Success criteria
62
-
63
- - With a test account and active drops campaign:
64
- - `tdm run` increases “minutes watched” for the targeted drop on Twitch Inventory without opening a stream in the browser.
65
- - Drops are claimed automatically when eligible (or a manual step is documented).
66
- - `tdm run --dry-run --verbose` logs intended watch and claim actions without performing spade/claim network calls.
67
- - `tdm status` shows a sensible state and active drop.
68
-
69
- ## Troubleshooting
70
-
71
- - **No channels / “No channel candidates”**: Ensure the game has live streams with Drops enabled; try a different game or relax `priorityMode`.
72
- - **Watch tick failed**: Spade URL extraction or auth may be failing; run with `--verbose` and check logs.
73
- - **Minutes not updating**: Twitch can delay updates; wait 2–3 minutes and refresh the Inventory page. Ensure you’re watching a channel that has Drops for that campaign.
1
+ # Drops validation playbook
2
+
3
+ This playbook helps confirm that the CLI actually advances and claims Twitch Drops as intended, without opening a browser stream.
4
+
5
+ ## Prerequisites
6
+
7
+ - A Twitch account (test account recommended).
8
+ - CLI installed and built: `npm run build`.
9
+ - Auth completed: `tdm auth login --no-open` (or paste token when prompted).
10
+
11
+ ## 1. Configure for a single game
12
+
13
+ - Choose an active Drops campaign with a **short first drop** (e.g. 15–30 minutes) so you can see progress quickly.
14
+ - In config (e.g. `~/.config/tdm/config.json` or project `tdm.config.json`), set:
15
+ - `priority`: `["<Game Name>"]` (exact game name from the campaign).
16
+ - `priorityMode`: `"priority_only"` so only that game is mined.
17
+ - `exclude`: `[]` (or leave default).
18
+
19
+ ## 2. Dry run (no network writes)
20
+
21
+ - Run with dry-run and verbose to see intended actions only:
22
+ ```bash
23
+ tdm run --dry-run --verbose
24
+ ```
25
+ - Confirm logs show:
26
+ - Inventory fetch and campaign list.
27
+ - Wanted games = your priority game.
28
+ - Channel fetch and selected channel.
29
+ - “Would send watch” (no real spade POST).
30
+ - “Would claim” for any claimable drop (no real ClaimDrop GQL).
31
+ - Stop with Ctrl+C.
32
+
33
+ ## 3. Live run and Twitch Inventory check
34
+
35
+ - In a browser, open [Twitch Drops Inventory](https://www.twitch.tv/drops/inventory) and log in with the same account.
36
+ - Note the current “minutes watched” (and “Claim” button if the drop is ready) for the target campaign.
37
+ - Start the miner:
38
+ ```bash
39
+ tdm run --verbose
40
+ ```
41
+ - Let it run for at least one watch interval (about 1 minute). You should see “Watch tick sent for channel …” in the logs.
42
+ - Refresh the Twitch Inventory page: “minutes watched” for the active drop should increase (may take 1–2 minutes to reflect).
43
+ - If the drop becomes claimable, the CLI should auto-claim; check logs for “Claimed drop” and confirm the drop shows as claimed in the Inventory.
44
+
45
+ ## 4. Compare with Python miner (optional)
46
+
47
+ - Using the same Twitch account and same priority game:
48
+ - Run the Python TwitchDropsMiner and note progression/claim time.
49
+ - Run the CLI with the same config and note progression/claim time.
50
+ - Progression and claim times should be comparable (allow for Twitch-side variance).
51
+
52
+ ## 5. Status command
53
+
54
+ - While the miner is running (or after it has run), in another terminal:
55
+ ```bash
56
+ tdm status
57
+ tdm status --json
58
+ ```
59
+ - Confirm `state` (e.g. WATCHING or MAINTENANCE) and `activeDrop` (e.g. “Game Name: Drop Name”) look correct.
60
+
61
+ ## Success criteria
62
+
63
+ - With a test account and active drops campaign:
64
+ - `tdm run` increases “minutes watched” for the targeted drop on Twitch Inventory without opening a stream in the browser.
65
+ - Drops are claimed automatically when eligible (or a manual step is documented).
66
+ - `tdm run --dry-run --verbose` logs intended watch and claim actions without performing spade/claim network calls.
67
+ - `tdm status` shows a sensible state and active drop.
68
+
69
+ ## Troubleshooting
70
+
71
+ - **No channels / “No channel candidates”**: Ensure the game has live streams with Drops enabled; try a different game or relax `priorityMode`.
72
+ - **Watch tick failed**: Spade URL extraction or auth may be failing; run with `--verbose` and check logs.
73
+ - **Minutes not updating**: Twitch can delay updates; wait 2–3 minutes and refresh the Inventory page. Ensure you’re watching a channel that has Drops for that campaign.
@@ -1,15 +1,15 @@
1
- ## Linux install (headless)
2
-
3
- - **Prereqs**: `node >= 20`, outbound HTTPS/WSS to Twitch.
4
- - Install globally:
5
-
6
- ```bash
7
- npm install -g twitchdropsminer-cli
8
- ```
9
-
10
- - Verify environment:
11
-
12
- ```bash
13
- tdm doctor
14
- ```
15
-
1
+ ## Linux install (headless)
2
+
3
+ - **Prereqs**: `node >= 20`, outbound HTTPS/WSS to Twitch.
4
+ - Install globally:
5
+
6
+ ```bash
7
+ npm install -g twitchdropsminer-cli
8
+ ```
9
+
10
+ - Verify environment:
11
+
12
+ ```bash
13
+ tdm doctor
14
+ ```
15
+
@@ -1,23 +1,23 @@
1
- ## Running as a service (systemd)
2
-
3
- ### Install user-level service
4
-
5
- ```bash
6
- tdm service install --user --autostart
7
- tdm service start
8
- ```
9
-
10
- ### Check status
11
-
12
- ```bash
13
- tdm service status
14
- ```
15
-
16
- ### Logs (journalctl)
17
-
18
- Use standard `journalctl` commands, e.g.:
19
-
20
- ```bash
21
- journalctl --user -u tdm.service -f
22
- ```
23
-
1
+ ## Running as a service (systemd)
2
+
3
+ ### Install user-level service
4
+
5
+ ```bash
6
+ tdm service install --user --autostart
7
+ tdm service start
8
+ ```
9
+
10
+ ### Check status
11
+
12
+ ```bash
13
+ tdm service status
14
+ ```
15
+
16
+ ### Logs (journalctl)
17
+
18
+ Use standard `journalctl` commands, e.g.:
19
+
20
+ ```bash
21
+ journalctl --user -u tdm.service -f
22
+ ```
23
+
@@ -1,13 +1,13 @@
1
- ## systemd hardening notes
2
-
3
- Recommended service-level settings:
4
-
5
- - `NoNewPrivileges=true`
6
- - `PrivateTmp=true`
7
- - `Restart=on-failure`
8
- - `RestartSec=5`
9
- - `After=network-online.target`
10
- - `Wants=network-online.target`
11
-
12
- Use user-level units for least privilege unless you explicitly need a system unit.
13
-
1
+ ## systemd hardening notes
2
+
3
+ Recommended service-level settings:
4
+
5
+ - `NoNewPrivileges=true`
6
+ - `PrivateTmp=true`
7
+ - `Restart=on-failure`
8
+ - `RestartSec=5`
9
+ - `After=network-online.target`
10
+ - `Wants=network-online.target`
11
+
12
+ Use user-level units for least privilege unless you explicitly need a system unit.
13
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twitchdropsminer-cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Headless CLI rewrite of DevilXD/TwitchDropsMiner for mining Twitch drops on servers.",
5
5
  "bin": {
6
6
  "tdm": "dist/cli/index.js"
@@ -1,17 +1,17 @@
1
- [Unit]
2
- Description=Twitch Drops Miner CLI
3
- After=network-online.target
4
- Wants=network-online.target
5
-
6
- [Service]
7
- Type=simple
8
- ExecStart={{NODE_PATH}} {{TDM_BIN}} run
9
- Restart=on-failure
10
- RestartSec=5
11
- NoNewPrivileges=true
12
- PrivateTmp=true
13
- Environment=TDM_LOG_LEVEL=info
14
-
15
- [Install]
16
- WantedBy=default.target
17
-
1
+ [Unit]
2
+ Description=Twitch Drops Miner CLI
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStart={{NODE_PATH}} {{TDM_BIN}} run
9
+ Restart=on-failure
10
+ RestartSec=5
11
+ NoNewPrivileges=true
12
+ PrivateTmp=true
13
+ Environment=TDM_LOG_LEVEL=info
14
+
15
+ [Install]
16
+ WantedBy=default.target
17
+