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,366 @@
|
|
|
1
|
+
import { StateMachine } from "./stateMachine.js";
|
|
2
|
+
import { WatchLoop } from "./watchLoop.js";
|
|
3
|
+
import { MaintenanceScheduler } from "./maintenance.js";
|
|
4
|
+
import { SessionManager } from "../auth/sessionManager.js";
|
|
5
|
+
import { GQL_OPERATIONS } from "../integrations/gqlOperations.js";
|
|
6
|
+
import { gqlRequest } from "../integrations/gqlClient.js";
|
|
7
|
+
import { canWatchChannel, sortChannelCandidates, shouldSwitchChannel } from "../domain/channel.js";
|
|
8
|
+
import { fetchChannelsForWantedGames } from "./channelService.js";
|
|
9
|
+
import { sendChannelWatch } from "../integrations/twitchSpade.js";
|
|
10
|
+
import { saveSessionState } from "../state/sessionState.js";
|
|
11
|
+
import { logger } from "./runtime.js";
|
|
12
|
+
import { buildInventoryFromGqlResponses } from "../domain/inventory.js";
|
|
13
|
+
import { loadConfig } from "../config/store.js";
|
|
14
|
+
import { TwitchPubSub } from "../integrations/twitchPubSub.js";
|
|
15
|
+
import { WS_TOPICS_LIMIT } from "./constants.js";
|
|
16
|
+
export class Miner {
|
|
17
|
+
state = new StateMachine();
|
|
18
|
+
watchLoop = new WatchLoop();
|
|
19
|
+
maintenance = new MaintenanceScheduler();
|
|
20
|
+
running = false;
|
|
21
|
+
config = null;
|
|
22
|
+
campaigns = [];
|
|
23
|
+
timeTriggers = [];
|
|
24
|
+
wantedGames = [];
|
|
25
|
+
channels = [];
|
|
26
|
+
watchingChannel = null;
|
|
27
|
+
userId = null;
|
|
28
|
+
lastInventoryFetchHour = 0;
|
|
29
|
+
spadeUrlCache = new Map();
|
|
30
|
+
pubsub = null;
|
|
31
|
+
dryRun = false;
|
|
32
|
+
async run(options) {
|
|
33
|
+
if (this.running) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.running = true;
|
|
37
|
+
this.dryRun = options?.dryRun ?? false;
|
|
38
|
+
this.config = loadConfig();
|
|
39
|
+
const session = new SessionManager();
|
|
40
|
+
const token = session.getAccessToken();
|
|
41
|
+
if (!token) {
|
|
42
|
+
throw new Error("Missing auth token. Run `tdm auth login --no-open` first.");
|
|
43
|
+
}
|
|
44
|
+
const validation = await session.validateAccessToken(token);
|
|
45
|
+
this.userId = validation.user_id;
|
|
46
|
+
logger.info("Auth validated. Starting miner.");
|
|
47
|
+
this.state.setState("INVENTORY_FETCH");
|
|
48
|
+
await this.tickState(token);
|
|
49
|
+
this.pubsub = new TwitchPubSub();
|
|
50
|
+
await this.pubsub.start();
|
|
51
|
+
this.setupPubSubHandlers(token);
|
|
52
|
+
this.subscribePubSub(token);
|
|
53
|
+
this.watchLoop.start(async () => {
|
|
54
|
+
if (this.state.state !== "IDLE") {
|
|
55
|
+
await this.tickState(token);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!this.watchingChannel || !this.userId) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.dryRun) {
|
|
62
|
+
logger.info(`[dry-run] Would send watch for channel ${this.watchingChannel.login} (id=${this.watchingChannel.id})`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const ok = await sendChannelWatch(this.watchingChannel, this.userId, token, {
|
|
66
|
+
spadeUrlCache: this.spadeUrlCache
|
|
67
|
+
});
|
|
68
|
+
if (ok) {
|
|
69
|
+
logger.info(`Watch tick sent for channel ${this.watchingChannel.login}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
logger.warn(`Watch tick failed for channel ${this.watchingChannel.login}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
saveSessionState({
|
|
76
|
+
state: this.state.state,
|
|
77
|
+
watchedChannelId: this.watchingChannel.id,
|
|
78
|
+
watchedChannelName: this.watchingChannel.login,
|
|
79
|
+
activeDropId: this.getActiveDropText() ?? undefined,
|
|
80
|
+
updatedAt: new Date().toISOString()
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
this.maintenance.start(60 * 1000, async () => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const currentHour = Math.floor(now / (60 * 60 * 1000));
|
|
86
|
+
if (currentHour > this.lastInventoryFetchHour) {
|
|
87
|
+
logger.info("Maintenance: hourly inventory refresh");
|
|
88
|
+
this.lastInventoryFetchHour = currentHour;
|
|
89
|
+
this.state.setState("INVENTORY_FETCH");
|
|
90
|
+
}
|
|
91
|
+
const pastTriggers = this.timeTriggers.filter((d) => {
|
|
92
|
+
const t = d.getTime();
|
|
93
|
+
return t > now - 60 * 1000 && t <= now;
|
|
94
|
+
});
|
|
95
|
+
if (pastTriggers.length > 0) {
|
|
96
|
+
logger.info("Maintenance: campaign time trigger");
|
|
97
|
+
this.state.setState("CHANNELS_CLEANUP");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
process.on("SIGINT", () => {
|
|
101
|
+
void this.shutdown();
|
|
102
|
+
});
|
|
103
|
+
process.on("SIGTERM", () => {
|
|
104
|
+
void this.shutdown();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async shutdown() {
|
|
108
|
+
this.running = false;
|
|
109
|
+
this.watchLoop.stop();
|
|
110
|
+
this.maintenance.stop();
|
|
111
|
+
if (this.pubsub) {
|
|
112
|
+
await this.pubsub.stop();
|
|
113
|
+
this.pubsub = null;
|
|
114
|
+
}
|
|
115
|
+
this.state.setState("EXIT");
|
|
116
|
+
saveSessionState({
|
|
117
|
+
state: "EXIT",
|
|
118
|
+
activeDropId: this.getActiveDropText() ?? undefined,
|
|
119
|
+
updatedAt: new Date().toISOString(),
|
|
120
|
+
watchedChannelId: this.watchingChannel?.id,
|
|
121
|
+
watchedChannelName: this.watchingChannel?.login
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async claimEligibleDrops(token) {
|
|
125
|
+
for (const campaign of this.campaigns) {
|
|
126
|
+
for (const drop of campaign.drops) {
|
|
127
|
+
if (!drop.canClaim || !drop.dropInstanceId)
|
|
128
|
+
continue;
|
|
129
|
+
if (this.dryRun) {
|
|
130
|
+
logger.info(`[dry-run] Would claim drop ${drop.name} (instanceId=${drop.dropInstanceId})`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await gqlRequest(GQL_OPERATIONS.ClaimDrop, token, {
|
|
135
|
+
input: { dropInstanceID: drop.dropInstanceId }
|
|
136
|
+
});
|
|
137
|
+
drop.markClaimed();
|
|
138
|
+
logger.info({ dropId: drop.id, instanceId: drop.dropInstanceId }, "Claimed drop");
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
logger.warn({ err, dropId: drop.id }, "Claim drop failed");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
getActiveDropText() {
|
|
147
|
+
for (const campaign of this.campaigns) {
|
|
148
|
+
const first = campaign.firstDrop;
|
|
149
|
+
if (first)
|
|
150
|
+
return `${campaign.gameName}: ${first.name}`;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
findDropByInstanceId(instanceId) {
|
|
155
|
+
for (const campaign of this.campaigns) {
|
|
156
|
+
for (const drop of campaign.drops) {
|
|
157
|
+
if (drop.dropInstanceId === instanceId)
|
|
158
|
+
return drop;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
setupPubSubHandlers(token) {
|
|
164
|
+
if (!this.pubsub || !this.userId)
|
|
165
|
+
return;
|
|
166
|
+
const userDropsTopic = `user-drop-events.${this.userId}`;
|
|
167
|
+
const notificationsTopic = `onsite-notifications.${this.userId}`;
|
|
168
|
+
this.pubsub.registerTopic(userDropsTopic, (msg) => {
|
|
169
|
+
const type = msg.type;
|
|
170
|
+
if (type === "drop-progress") {
|
|
171
|
+
const data = msg.data;
|
|
172
|
+
const instanceId = data?.drop_instance_id ?? data?.dropInstanceID;
|
|
173
|
+
const minutes = Number(data?.current_progress_minutes ?? data?.currentMinutesWatched ?? 0);
|
|
174
|
+
if (instanceId && Number.isFinite(minutes)) {
|
|
175
|
+
const drop = this.findDropByInstanceId(String(instanceId));
|
|
176
|
+
if (drop) {
|
|
177
|
+
drop.updateMinutes(minutes);
|
|
178
|
+
logger.debug({ instanceId, minutes }, "Drop progress from PubSub");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (type === "drop-claim" || type === "drop_claim") {
|
|
183
|
+
const data = msg.data;
|
|
184
|
+
const instanceId = data?.drop_instance_id ?? data?.dropInstanceID;
|
|
185
|
+
if (instanceId) {
|
|
186
|
+
const drop = this.findDropByInstanceId(String(instanceId));
|
|
187
|
+
if (drop) {
|
|
188
|
+
drop.markClaimed();
|
|
189
|
+
logger.info({ instanceId }, "Drop claimed from PubSub");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
this.state.setState("CHANNELS_CLEANUP");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
this.pubsub.registerTopic(notificationsTopic, () => {
|
|
196
|
+
logger.debug("Onsite notification received, requesting inventory refresh");
|
|
197
|
+
this.state.setState("INVENTORY_FETCH");
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
subscribePubSub(token) {
|
|
201
|
+
if (!this.pubsub || !this.userId)
|
|
202
|
+
return;
|
|
203
|
+
const userTopics = [
|
|
204
|
+
`user-drop-events.${this.userId}`,
|
|
205
|
+
`onsite-notifications.${this.userId}`
|
|
206
|
+
];
|
|
207
|
+
const channelTopics = this.channels
|
|
208
|
+
.slice(0, Math.max(0, WS_TOPICS_LIMIT - userTopics.length))
|
|
209
|
+
.map((ch) => `video-playback-by-id.${ch.id}`);
|
|
210
|
+
for (const topic of channelTopics) {
|
|
211
|
+
this.pubsub.registerTopic(topic, () => {
|
|
212
|
+
logger.debug("Stream state update, requesting channels cleanup");
|
|
213
|
+
this.state.setState("CHANNELS_CLEANUP");
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
this.pubsub.listen(userTopics, token);
|
|
217
|
+
if (channelTopics.length > 0) {
|
|
218
|
+
this.pubsub.listen(channelTopics, token);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async tickState(token) {
|
|
222
|
+
if (!this.running) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (this.state.state === "INVENTORY_FETCH") {
|
|
226
|
+
await this.fetchInventory(token);
|
|
227
|
+
this.lastInventoryFetchHour = Math.floor(Date.now() / (60 * 60 * 1000));
|
|
228
|
+
this.state.setState("GAMES_UPDATE");
|
|
229
|
+
}
|
|
230
|
+
if (this.state.state === "GAMES_UPDATE") {
|
|
231
|
+
await this.claimEligibleDrops(token);
|
|
232
|
+
this.updateWantedGames();
|
|
233
|
+
this.state.setState("CHANNELS_CLEANUP");
|
|
234
|
+
}
|
|
235
|
+
if (this.state.state === "CHANNELS_CLEANUP") {
|
|
236
|
+
this.cleanupChannels();
|
|
237
|
+
this.state.setState("CHANNELS_FETCH");
|
|
238
|
+
}
|
|
239
|
+
if (this.state.state === "CHANNELS_FETCH") {
|
|
240
|
+
await this.fetchChannels(token);
|
|
241
|
+
this.state.setState("CHANNEL_SWITCH");
|
|
242
|
+
}
|
|
243
|
+
if (this.state.state === "CHANNEL_SWITCH") {
|
|
244
|
+
this.switchChannel();
|
|
245
|
+
this.state.setState(this.watchingChannel ? "IDLE" : "CHANNELS_FETCH");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async fetchInventory(token) {
|
|
249
|
+
const inventoryResponse = await gqlRequest(GQL_OPERATIONS.Inventory, token);
|
|
250
|
+
const campaignsResponse = await gqlRequest(GQL_OPERATIONS.Campaigns, token);
|
|
251
|
+
const cfg = this.config ?? loadConfig();
|
|
252
|
+
const built = buildInventoryFromGqlResponses(inventoryResponse, campaignsResponse, { enableBadgesEmotes: cfg.enableBadgesEmotes });
|
|
253
|
+
this.campaigns = built.campaigns;
|
|
254
|
+
this.timeTriggers = built.timeTriggers;
|
|
255
|
+
logger.debug({
|
|
256
|
+
campaigns: this.campaigns.map((c) => ({
|
|
257
|
+
id: c.id,
|
|
258
|
+
game: c.gameName,
|
|
259
|
+
eligible: c.eligible,
|
|
260
|
+
active: c.active,
|
|
261
|
+
upcoming: c.upcoming
|
|
262
|
+
}))
|
|
263
|
+
}, "Inventory fetched and campaigns built");
|
|
264
|
+
}
|
|
265
|
+
updateWantedGames() {
|
|
266
|
+
// Reload config from disk so we always use latest priority (e.g. after tdm games --add or manual edit).
|
|
267
|
+
const cfg = loadConfig();
|
|
268
|
+
const exclude = new Set(cfg.exclude);
|
|
269
|
+
const priority = cfg.priority;
|
|
270
|
+
const priorityMode = cfg.priorityMode;
|
|
271
|
+
const priorityOnly = priorityMode === "priority_only";
|
|
272
|
+
const nextHour = new Date(Date.now() + 60 * 60 * 1000);
|
|
273
|
+
const earnable = this.campaigns.filter((c) => c.canEarnWithin(nextHour));
|
|
274
|
+
if (earnable.length === 0 && this.campaigns.length > 0) {
|
|
275
|
+
logger.warn({
|
|
276
|
+
totalCampaigns: this.campaigns.length,
|
|
277
|
+
campaignGames: this.campaigns.slice(0, 5).map((c) => ({
|
|
278
|
+
game: c.gameName,
|
|
279
|
+
eligible: c.eligible,
|
|
280
|
+
active: c.active,
|
|
281
|
+
canEarnWithin: c.canEarnWithin(nextHour)
|
|
282
|
+
})),
|
|
283
|
+
priority,
|
|
284
|
+
priorityMode
|
|
285
|
+
}, "No campaigns passed canEarnWithin; check campaign dates/eligibility");
|
|
286
|
+
}
|
|
287
|
+
let campaigns = earnable;
|
|
288
|
+
if (!priorityOnly) {
|
|
289
|
+
if (priorityMode === "ending_soonest") {
|
|
290
|
+
campaigns = campaigns.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime());
|
|
291
|
+
}
|
|
292
|
+
else if (priorityMode === "low_avbl_first") {
|
|
293
|
+
campaigns = campaigns.sort((a, b) => a.availability - b.availability);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
campaigns = campaigns.sort((a, b) => {
|
|
297
|
+
const ia = priority.indexOf(a.gameName);
|
|
298
|
+
const ib = priority.indexOf(b.gameName);
|
|
299
|
+
const pa = ia === -1 ? Number.MAX_SAFE_INTEGER : ia;
|
|
300
|
+
const pb = ib === -1 ? Number.MAX_SAFE_INTEGER : ib;
|
|
301
|
+
return pa - pb;
|
|
302
|
+
});
|
|
303
|
+
const wanted = [];
|
|
304
|
+
for (const campaign of campaigns) {
|
|
305
|
+
const game = campaign.gameName;
|
|
306
|
+
if (wanted.includes(game))
|
|
307
|
+
continue;
|
|
308
|
+
if (exclude.has(game))
|
|
309
|
+
continue;
|
|
310
|
+
if (priorityOnly && !priority.includes(game))
|
|
311
|
+
continue;
|
|
312
|
+
wanted.push(game);
|
|
313
|
+
}
|
|
314
|
+
// If priority_only and we have priority set but no earnable campaign was in the list,
|
|
315
|
+
// still add priority games that exist in our campaign list so we fetch channels for them.
|
|
316
|
+
if (wanted.length === 0 && priorityOnly && priority.length > 0) {
|
|
317
|
+
const campaignGameNames = new Set(this.campaigns.map((c) => c.gameName));
|
|
318
|
+
for (const game of priority) {
|
|
319
|
+
if (exclude.has(game))
|
|
320
|
+
continue;
|
|
321
|
+
if (campaignGameNames.has(game)) {
|
|
322
|
+
wanted.push(game);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (wanted.length > 0) {
|
|
326
|
+
logger.debug({ addedFromPriority: wanted }, "No earnable campaigns in priority; using priority games for channel fetch");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
this.wantedGames = wanted;
|
|
330
|
+
logger.info({ wantedGames: this.wantedGames }, "Updated wanted games");
|
|
331
|
+
}
|
|
332
|
+
cleanupChannels() {
|
|
333
|
+
this.channels = this.channels.filter((ch) => canWatchChannel(ch, this.wantedGames));
|
|
334
|
+
if (this.watchingChannel && !canWatchChannel(this.watchingChannel, this.wantedGames)) {
|
|
335
|
+
this.watchingChannel = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async fetchChannels(token) {
|
|
339
|
+
if (this.wantedGames.length === 0) {
|
|
340
|
+
this.channels = [];
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.channels = await fetchChannelsForWantedGames(token, {
|
|
344
|
+
wantedGames: this.wantedGames,
|
|
345
|
+
campaigns: this.campaigns,
|
|
346
|
+
maxChannels: 100
|
|
347
|
+
});
|
|
348
|
+
logger.info({ count: this.channels.length, wantedGames: this.wantedGames }, "Fetched channels");
|
|
349
|
+
}
|
|
350
|
+
switchChannel() {
|
|
351
|
+
const candidates = sortChannelCandidates(this.channels, this.wantedGames).filter((ch) => canWatchChannel(ch, this.wantedGames));
|
|
352
|
+
const best = candidates[0] ?? null;
|
|
353
|
+
if (best && shouldSwitchChannel(this.watchingChannel, best, this.wantedGames)) {
|
|
354
|
+
this.watchingChannel = best;
|
|
355
|
+
logger.info(`Watching channel: ${this.watchingChannel.login}`);
|
|
356
|
+
}
|
|
357
|
+
else if (!this.watchingChannel && best) {
|
|
358
|
+
this.watchingChannel = best;
|
|
359
|
+
logger.info(`Watching channel: ${this.watchingChannel.login}`);
|
|
360
|
+
}
|
|
361
|
+
else if (!best) {
|
|
362
|
+
this.watchingChannel = null;
|
|
363
|
+
logger.info("No channel candidates available.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const logger = pino({
|
|
6
|
+
level: process.env.TDM_LOG_LEVEL || "info"
|
|
7
|
+
});
|
|
8
|
+
let lockFd = null;
|
|
9
|
+
function lockPath() {
|
|
10
|
+
const dir = path.join(os.homedir(), ".local", "state", "tdm");
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
12
|
+
return path.join(dir, "lock.file");
|
|
13
|
+
}
|
|
14
|
+
export function ensureSingleInstanceLock() {
|
|
15
|
+
const p = lockPath();
|
|
16
|
+
try {
|
|
17
|
+
lockFd = fs.openSync(p, "wx", 0o600);
|
|
18
|
+
fs.writeFileSync(lockFd, String(process.pid));
|
|
19
|
+
process.on("exit", () => {
|
|
20
|
+
try {
|
|
21
|
+
if (lockFd !== null) {
|
|
22
|
+
fs.closeSync(lockFd);
|
|
23
|
+
fs.unlinkSync(p);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error("Another tdm instance appears to be running.");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { WATCH_INTERVAL_MS } from "./constants.js";
|
|
2
|
+
export class WatchLoop {
|
|
3
|
+
timer = null;
|
|
4
|
+
running = false;
|
|
5
|
+
start(onTick) {
|
|
6
|
+
if (this.running) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
this.running = true;
|
|
10
|
+
const tick = async () => {
|
|
11
|
+
if (!this.running) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await onTick();
|
|
15
|
+
this.timer = setTimeout(tick, WATCH_INTERVAL_MS);
|
|
16
|
+
};
|
|
17
|
+
void tick();
|
|
18
|
+
}
|
|
19
|
+
stop() {
|
|
20
|
+
this.running = false;
|
|
21
|
+
if (this.timer) {
|
|
22
|
+
clearTimeout(this.timer);
|
|
23
|
+
this.timer = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function canWatchChannel(channel, wantedGames) {
|
|
2
|
+
if (!channel.online) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
if (!channel.gameName) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (!channel.dropsEnabled) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return wantedGames.includes(channel.gameName);
|
|
12
|
+
}
|
|
13
|
+
/** Lower = higher priority (ACL first, then wanted-game order, then viewers desc). */
|
|
14
|
+
export function getChannelPriority(channel, wantedGames) {
|
|
15
|
+
const aclBonus = channel.aclBased === true ? 0 : 1;
|
|
16
|
+
const priorityIndex = wantedGames.indexOf(channel.gameName ?? "");
|
|
17
|
+
const gameOrder = priorityIndex === -1 ? Number.MAX_SAFE_INTEGER : priorityIndex;
|
|
18
|
+
return aclBonus * 1e9 + gameOrder * 1e6 + (1e6 - Math.min(channel.viewers, 1e6 - 1));
|
|
19
|
+
}
|
|
20
|
+
export function shouldSwitchChannel(current, candidate, wantedGames) {
|
|
21
|
+
if (!current)
|
|
22
|
+
return true;
|
|
23
|
+
if (!canWatchChannel(candidate, wantedGames))
|
|
24
|
+
return false;
|
|
25
|
+
return getChannelPriority(candidate, wantedGames) < getChannelPriority(current, wantedGames);
|
|
26
|
+
}
|
|
27
|
+
export function sortChannelCandidates(channels, wantedGames) {
|
|
28
|
+
return [...channels].sort((a, b) => {
|
|
29
|
+
return getChannelPriority(a, wantedGames) - getChannelPriority(b, wantedGames);
|
|
30
|
+
});
|
|
31
|
+
}
|