@akilles/soundcloud-watcher 1.0.3 → 2.0.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/index.ts +39 -82
- package/package.json +2 -5
- package/soundcloud_watcher.ts +179 -273
package/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'path';
|
|
1
|
+
import { SoundCloudWatcher } from './soundcloud_watcher';
|
|
3
2
|
|
|
4
|
-
interface
|
|
3
|
+
interface PluginConfig {
|
|
5
4
|
enabled: boolean;
|
|
6
5
|
clientId: string;
|
|
7
6
|
clientSecret: string;
|
|
@@ -9,58 +8,30 @@ interface SoundCloudConfig {
|
|
|
9
8
|
checkIntervalHours: number;
|
|
10
9
|
myTracksLimit: number;
|
|
11
10
|
dormantDays: number;
|
|
12
|
-
sessionKey?: string;
|
|
11
|
+
sessionKey?: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
15
14
|
export default function register(api: any) {
|
|
16
15
|
const logger = api.getLogger?.() || console;
|
|
17
16
|
let checkInterval: NodeJS.Timeout | null = null;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
env: {
|
|
31
|
-
...process.env,
|
|
32
|
-
// Pass config via environment vars
|
|
33
|
-
SOUNDCLOUD_CLIENT_ID: api.getConfig?.()?.clientId || '',
|
|
34
|
-
SOUNDCLOUD_CLIENT_SECRET: api.getConfig?.()?.clientSecret || '',
|
|
35
|
-
MY_USERNAME: api.getConfig?.()?.username || '',
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
let stdout = '';
|
|
40
|
-
let stderr = '';
|
|
41
|
-
|
|
42
|
-
proc.stdout?.on('data', (data) => {
|
|
43
|
-
stdout += data.toString();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
proc.stderr?.on('data', (data) => {
|
|
47
|
-
stderr += data.toString();
|
|
17
|
+
let watcher: SoundCloudWatcher | null = null;
|
|
18
|
+
|
|
19
|
+
function getWatcher(): SoundCloudWatcher {
|
|
20
|
+
if (!watcher) {
|
|
21
|
+
const config = api.getConfig() as PluginConfig;
|
|
22
|
+
watcher = new SoundCloudWatcher({
|
|
23
|
+
clientId: config.clientId || '',
|
|
24
|
+
clientSecret: config.clientSecret || '',
|
|
25
|
+
username: config.username || '',
|
|
26
|
+
myTracksLimit: config.myTracksLimit,
|
|
27
|
+
dormantDays: config.dormantDays,
|
|
28
|
+
logger: (...args: any[]) => logger.debug(...args),
|
|
48
29
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
resolve({ stdout, stderr, code: code || 0 });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
proc.on('error', (err) => {
|
|
55
|
-
resolve({ stdout, stderr: err.message, code: 1 });
|
|
56
|
-
});
|
|
57
|
-
});
|
|
30
|
+
}
|
|
31
|
+
return watcher;
|
|
58
32
|
}
|
|
59
33
|
|
|
60
|
-
|
|
61
|
-
* Run a check for SoundCloud updates
|
|
62
|
-
*/
|
|
63
|
-
async function checkForUpdates(config: SoundCloudConfig, sessionKey?: string) {
|
|
34
|
+
async function checkForUpdates(config: PluginConfig, sessionKey?: string) {
|
|
64
35
|
if (!config.enabled) {
|
|
65
36
|
logger.debug('SoundCloud watcher is disabled');
|
|
66
37
|
return;
|
|
@@ -68,23 +39,16 @@ export default function register(api: any) {
|
|
|
68
39
|
|
|
69
40
|
try {
|
|
70
41
|
logger.info('Running SoundCloud check...');
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
if (result.code !== 0) {
|
|
74
|
-
logger.error('SoundCloud check failed:', result.stderr || result.stdout);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
42
|
+
const message = await getWatcher().runCron();
|
|
77
43
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
logger.info('SoundCloud updates found:', result.stdout);
|
|
44
|
+
if (message) {
|
|
45
|
+
logger.info('SoundCloud updates found');
|
|
81
46
|
|
|
82
|
-
// If a session key is provided, send the notification there
|
|
83
47
|
if (sessionKey) {
|
|
84
48
|
try {
|
|
85
49
|
await api.tools.sessions_send({
|
|
86
50
|
sessionKey,
|
|
87
|
-
message
|
|
51
|
+
message,
|
|
88
52
|
});
|
|
89
53
|
} catch (err) {
|
|
90
54
|
logger.error('Failed to send notification:', err);
|
|
@@ -98,22 +62,17 @@ export default function register(api: any) {
|
|
|
98
62
|
}
|
|
99
63
|
}
|
|
100
64
|
|
|
101
|
-
|
|
102
|
-
* Start the periodic check loop
|
|
103
|
-
*/
|
|
104
|
-
function startChecking(config: SoundCloudConfig, sessionKey?: string) {
|
|
65
|
+
function startChecking(config: PluginConfig, sessionKey?: string) {
|
|
105
66
|
if (checkInterval) {
|
|
106
67
|
clearInterval(checkInterval);
|
|
107
68
|
}
|
|
108
69
|
|
|
109
70
|
const intervalMs = config.checkIntervalHours * 60 * 60 * 1000;
|
|
110
71
|
|
|
111
|
-
// Run immediately on startup
|
|
112
72
|
checkForUpdates(config, sessionKey).catch((err) => {
|
|
113
73
|
logger.error('Initial SoundCloud check failed:', err);
|
|
114
74
|
});
|
|
115
75
|
|
|
116
|
-
// Then run periodically
|
|
117
76
|
checkInterval = setInterval(() => {
|
|
118
77
|
checkForUpdates(config, sessionKey).catch((err) => {
|
|
119
78
|
logger.error('Periodic SoundCloud check failed:', err);
|
|
@@ -123,9 +82,6 @@ export default function register(api: any) {
|
|
|
123
82
|
logger.info(`SoundCloud watcher started (checking every ${config.checkIntervalHours}h)`);
|
|
124
83
|
}
|
|
125
84
|
|
|
126
|
-
/**
|
|
127
|
-
* Stop the periodic check loop
|
|
128
|
-
*/
|
|
129
85
|
function stopChecking() {
|
|
130
86
|
if (checkInterval) {
|
|
131
87
|
clearInterval(checkInterval);
|
|
@@ -139,12 +95,12 @@ export default function register(api: any) {
|
|
|
139
95
|
name: 'soundcloud-setup',
|
|
140
96
|
description: 'Interactive setup for SoundCloud credentials',
|
|
141
97
|
handler: async (ctx: any) => {
|
|
142
|
-
const config = api.getConfig() as
|
|
98
|
+
const config = api.getConfig() as PluginConfig;
|
|
143
99
|
|
|
144
100
|
let message = '# SoundCloud Watcher Setup\n\n';
|
|
145
101
|
|
|
146
102
|
if (config.clientId && config.clientSecret && config.username) {
|
|
147
|
-
message += '
|
|
103
|
+
message += 'Already configured!\n\n';
|
|
148
104
|
message += `- Username: ${config.username}\n`;
|
|
149
105
|
message += `- Client ID: ${config.clientId.substring(0, 8)}...${config.clientId.slice(-4)}\n`;
|
|
150
106
|
message += `- Check interval: ${config.checkIntervalHours} hours\n`;
|
|
@@ -175,7 +131,7 @@ export default function register(api: any) {
|
|
|
175
131
|
message += '}\n';
|
|
176
132
|
message += '```\n\n';
|
|
177
133
|
message += '3. Restart: `openclaw gateway restart`\n';
|
|
178
|
-
message += '4.
|
|
134
|
+
message += '4. Verify in chat: `/soundcloud-setup`\n';
|
|
179
135
|
}
|
|
180
136
|
|
|
181
137
|
return { text: message };
|
|
@@ -186,8 +142,8 @@ export default function register(api: any) {
|
|
|
186
142
|
name: 'soundcloud-status',
|
|
187
143
|
description: 'Show SoundCloud watcher status',
|
|
188
144
|
handler: async (ctx: any) => {
|
|
189
|
-
const result = await
|
|
190
|
-
return { text: result
|
|
145
|
+
const result = await getWatcher().status();
|
|
146
|
+
return { text: result };
|
|
191
147
|
},
|
|
192
148
|
});
|
|
193
149
|
|
|
@@ -195,8 +151,8 @@ export default function register(api: any) {
|
|
|
195
151
|
name: 'soundcloud-check',
|
|
196
152
|
description: 'Run an immediate SoundCloud check',
|
|
197
153
|
handler: async (ctx: any) => {
|
|
198
|
-
const result = await
|
|
199
|
-
return { text: result
|
|
154
|
+
const result = await getWatcher().check();
|
|
155
|
+
return { text: result };
|
|
200
156
|
},
|
|
201
157
|
});
|
|
202
158
|
|
|
@@ -207,8 +163,8 @@ export default function register(api: any) {
|
|
|
207
163
|
if (usernames.length === 0) {
|
|
208
164
|
return { text: 'Usage: /soundcloud-add <username> [username2] ...' };
|
|
209
165
|
}
|
|
210
|
-
const result = await
|
|
211
|
-
return { text: result
|
|
166
|
+
const result = await getWatcher().addArtists(usernames);
|
|
167
|
+
return { text: result };
|
|
212
168
|
},
|
|
213
169
|
});
|
|
214
170
|
|
|
@@ -219,8 +175,8 @@ export default function register(api: any) {
|
|
|
219
175
|
if (!username) {
|
|
220
176
|
return { text: 'Usage: /soundcloud-remove <username>' };
|
|
221
177
|
}
|
|
222
|
-
const result = await
|
|
223
|
-
return { text: result
|
|
178
|
+
const result = await getWatcher().removeArtist(username);
|
|
179
|
+
return { text: result };
|
|
224
180
|
},
|
|
225
181
|
});
|
|
226
182
|
|
|
@@ -228,13 +184,14 @@ export default function register(api: any) {
|
|
|
228
184
|
name: 'soundcloud-list',
|
|
229
185
|
description: 'List all tracked artists',
|
|
230
186
|
handler: async (ctx: any) => {
|
|
231
|
-
const result = await
|
|
232
|
-
return { text: result
|
|
187
|
+
const result = await getWatcher().listArtists();
|
|
188
|
+
return { text: result };
|
|
233
189
|
},
|
|
234
190
|
});
|
|
235
191
|
|
|
236
192
|
// Handle config changes
|
|
237
|
-
api.onConfigChange?.((config:
|
|
193
|
+
api.onConfigChange?.((config: PluginConfig) => {
|
|
194
|
+
watcher = null; // Reset watcher so it picks up new config
|
|
238
195
|
if (config.enabled) {
|
|
239
196
|
const sessionKey = config.sessionKey || 'agent:main:main';
|
|
240
197
|
startChecking(config, sessionKey);
|
|
@@ -244,7 +201,7 @@ export default function register(api: any) {
|
|
|
244
201
|
});
|
|
245
202
|
|
|
246
203
|
// Initialize on load
|
|
247
|
-
const initialConfig = api.getConfig() as
|
|
204
|
+
const initialConfig = api.getConfig() as PluginConfig;
|
|
248
205
|
if (initialConfig.enabled) {
|
|
249
206
|
const sessionKey = initialConfig.sessionKey || 'agent:main:main';
|
|
250
207
|
startChecking(initialConfig, sessionKey);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akilles/soundcloud-watcher",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"openclaw": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"preinstall": "echo 'Installing SoundCloud Watcher dependencies...'",
|
|
13
|
-
"postinstall": "echo 'SoundCloud Watcher installed
|
|
13
|
+
"postinstall": "echo '\n✓ SoundCloud Watcher installed!\n\nNext steps:\n1. Get API credentials: https://soundcloud.com/you/apps\n2. Add to ~/.openclaw/openclaw.json:\n\n \"soundcloud-watcher\": {\n \"enabled\": true,\n \"config\": {\n \"clientId\": \"YOUR_CLIENT_ID\",\n \"clientSecret\": \"YOUR_CLIENT_SECRET\",\n \"username\": \"your_soundcloud_username\"\n }\n }\n\n3. Restart: openclaw gateway restart\n4. Verify in chat: /soundcloud-setup\n'",
|
|
14
14
|
"test": "echo \"No tests yet\""
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -39,9 +39,6 @@
|
|
|
39
39
|
"README.md",
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
|
-
"dependencies": {
|
|
43
|
-
"tsx": "^4.0.0"
|
|
44
|
-
},
|
|
45
42
|
"peerDependencies": {
|
|
46
43
|
"@types/node": "^22.0.0"
|
|
47
44
|
},
|
package/soundcloud_watcher.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
1
|
/**
|
|
3
|
-
* SoundCloud Watcher:
|
|
2
|
+
* SoundCloud Watcher: Module for tracking your SoundCloud account
|
|
4
3
|
* and getting notified about new releases from artists you care about.
|
|
5
4
|
*
|
|
6
5
|
* Features:
|
|
@@ -9,21 +8,8 @@
|
|
|
9
8
|
* - New release detection from a curated artist list
|
|
10
9
|
* - Dormant artist throttling (skip inactive artists to save API calls)
|
|
11
10
|
* - Rate limit backoff (exponential backoff on 429s)
|
|
12
|
-
* - Single cron entry runs everything
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
* 1. Create config file (see PATHS below) with your SoundCloud API credentials
|
|
16
|
-
* 2. Run: npx tsx soundcloud_cron.ts add <artist_username>
|
|
17
|
-
* 3. Run: npx tsx soundcloud_cron.ts check
|
|
18
|
-
* 4. Add to cron: 0 * /6 * * * npx tsx /path/to/soundcloud_cron.ts cron
|
|
19
|
-
*
|
|
20
|
-
* Config file format (one KEY=VALUE per line):
|
|
21
|
-
* SOUNDCLOUD_CLIENT_ID=your_client_id
|
|
22
|
-
* SOUNDCLOUD_CLIENT_SECRET=your_client_secret
|
|
23
|
-
* SOUNDCLOUD_ACCESS_TOKEN=auto_managed_by_script
|
|
24
|
-
* MY_USERNAME=your_soundcloud_username
|
|
25
|
-
*
|
|
26
|
-
* Requirements: Node.js 22+ (uses built-in fetch), npx tsx (or ts-node)
|
|
12
|
+
* Exported as a class for direct import (no subprocess spawning).
|
|
27
13
|
*/
|
|
28
14
|
|
|
29
15
|
import * as fs from "fs";
|
|
@@ -31,72 +17,48 @@ import * as path from "path";
|
|
|
31
17
|
import * as os from "os";
|
|
32
18
|
|
|
33
19
|
// =============================================================================
|
|
34
|
-
// CONFIGURATION
|
|
20
|
+
// CONFIGURATION
|
|
35
21
|
// =============================================================================
|
|
36
22
|
|
|
37
|
-
/** Your SoundCloud username (the URL slug, e.g. soundcloud.com/THIS_PART) */
|
|
38
|
-
const MY_USERNAME = "your_username";
|
|
39
|
-
|
|
40
|
-
/** Where to store config and data files - using OpenClaw standard paths */
|
|
41
23
|
const OPENCLAW_DIR = path.join(os.homedir(), ".openclaw");
|
|
42
24
|
const CONFIG_FILE = path.join(OPENCLAW_DIR, "secrets", "soundcloud.env");
|
|
43
25
|
const ACCOUNT_DATA = path.join(OPENCLAW_DIR, "data", "soundcloud_tracking.json");
|
|
44
26
|
const ARTISTS_DATA = path.join(OPENCLAW_DIR, "data", "artists.json");
|
|
45
27
|
const BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_backoff.json");
|
|
46
28
|
|
|
47
|
-
// --- Notification settings ---
|
|
48
|
-
/** Set to true to send notifications via a gateway, false for stdout only */
|
|
49
|
-
const NOTIFICATIONS_ENABLED = false;
|
|
50
|
-
const GATEWAY_CONFIG = path.join(OPENCLAW_DIR, "gateway.json");
|
|
51
|
-
const GATEWAY_SESSION_KEY = "default";
|
|
52
|
-
const GATEWAY_PORT_DEFAULT = 8080;
|
|
53
|
-
const GATEWAY_ENDPOINT = "/tools/invoke";
|
|
54
|
-
const GATEWAY_TOOL_NAME = "sessions_send";
|
|
55
|
-
|
|
56
29
|
// =============================================================================
|
|
57
|
-
// TUNING
|
|
30
|
+
// TUNING
|
|
58
31
|
// =============================================================================
|
|
59
32
|
|
|
60
|
-
/** How many of YOUR recent tracks to monitor for likes/reposts */
|
|
61
|
-
const MY_TRACKS_LIMIT = 10;
|
|
62
|
-
|
|
63
|
-
/** How many recent tracks to fetch per artist when checking for new releases */
|
|
64
33
|
const ARTIST_TRACKS_LIMIT = 5;
|
|
65
|
-
|
|
66
|
-
/** How many tracks to fetch when first adding an artist (seeds known tracks) */
|
|
67
34
|
const ARTIST_ADD_LIMIT = 50;
|
|
68
|
-
|
|
69
|
-
/** Artists who haven't uploaded in this many days are considered "dormant" */
|
|
70
|
-
const DORMANT_DAYS = 90;
|
|
71
|
-
|
|
72
|
-
/** Dormant artists are only checked every N days instead of every run */
|
|
73
35
|
const DORMANT_CHECK_INTERVAL_DAYS = 7;
|
|
74
|
-
|
|
75
|
-
/** Max stored track IDs per artist (older ones pruned to save disk/memory) */
|
|
76
36
|
const MAX_KNOWN_TRACKS = 50;
|
|
77
|
-
|
|
78
|
-
/** Max likers to fetch per track */
|
|
79
37
|
const MAX_LIKERS_PER_TRACK = 50;
|
|
80
|
-
|
|
81
|
-
/** Followers pagination page size (SoundCloud max is 200) */
|
|
82
38
|
const FOLLOWERS_PAGE_SIZE = 200;
|
|
83
39
|
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
const BACKOFF_MAX_SECONDS = 7200; // 2 hour ceiling
|
|
40
|
+
const BACKOFF_BASE_SECONDS = 300;
|
|
41
|
+
const BACKOFF_MAX_SECONDS = 7200;
|
|
87
42
|
|
|
88
|
-
// --- Timeouts ---
|
|
89
43
|
const API_TIMEOUT_MS = 15_000;
|
|
90
|
-
const GATEWAY_TIMEOUT_MS = 60_000;
|
|
91
44
|
|
|
92
45
|
// =============================================================================
|
|
93
|
-
// INTERNALS
|
|
46
|
+
// INTERNALS
|
|
94
47
|
// =============================================================================
|
|
95
48
|
|
|
96
49
|
const API_BASE = "https://api.soundcloud.com";
|
|
97
50
|
|
|
98
51
|
// -- Types --------------------------------------------------------------------
|
|
99
52
|
|
|
53
|
+
export interface SoundCloudWatcherConfig {
|
|
54
|
+
clientId: string;
|
|
55
|
+
clientSecret: string;
|
|
56
|
+
username: string;
|
|
57
|
+
myTracksLimit?: number;
|
|
58
|
+
dormantDays?: number;
|
|
59
|
+
logger?: (...args: any[]) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
100
62
|
interface UserInfo {
|
|
101
63
|
username: string;
|
|
102
64
|
display_name: string;
|
|
@@ -159,20 +121,14 @@ function daysSince(isoDate: string): number | null {
|
|
|
159
121
|
return isNaN(ms) ? null : Math.floor(ms / 86_400_000);
|
|
160
122
|
}
|
|
161
123
|
|
|
162
|
-
/**
|
|
163
|
-
* Parse SoundCloud timestamps into Unix ms.
|
|
164
|
-
* SoundCloud returns dates like: "2026/01/22 16:22:27 +0000"
|
|
165
|
-
*/
|
|
166
124
|
function parseTimestamp(ts: string | null | undefined): number {
|
|
167
125
|
if (!ts) return NaN;
|
|
168
126
|
try {
|
|
169
|
-
|
|
170
|
-
let cleaned = ts.replace(/\//g, "-").replace(" ", "T"); // first space only
|
|
127
|
+
let cleaned = ts.replace(/\//g, "-").replace(" ", "T");
|
|
171
128
|
cleaned = cleaned.replace(" +0000", "+00:00").replace("Z", "+00:00");
|
|
172
129
|
const d = new Date(cleaned);
|
|
173
130
|
return d.getTime();
|
|
174
131
|
} catch {
|
|
175
|
-
console.log(`Warning: Could not parse timestamp '${ts}'`);
|
|
176
132
|
return NaN;
|
|
177
133
|
}
|
|
178
134
|
}
|
|
@@ -205,26 +161,26 @@ function num(val: unknown): number {
|
|
|
205
161
|
// -- Config -------------------------------------------------------------------
|
|
206
162
|
|
|
207
163
|
class Config {
|
|
208
|
-
clientId
|
|
209
|
-
clientSecret
|
|
164
|
+
clientId: string;
|
|
165
|
+
clientSecret: string;
|
|
210
166
|
accessToken = "";
|
|
211
|
-
myUsername
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
167
|
+
myUsername: string;
|
|
168
|
+
|
|
169
|
+
constructor(clientId: string, clientSecret: string, username: string) {
|
|
170
|
+
this.clientId = clientId;
|
|
171
|
+
this.clientSecret = clientSecret;
|
|
172
|
+
this.myUsername = username;
|
|
173
|
+
|
|
174
|
+
// Load persisted access token from env file if it exists
|
|
175
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
176
|
+
for (const line of fs.readFileSync(CONFIG_FILE, "utf-8").split("\n")) {
|
|
177
|
+
if (!line.includes("=") || line.startsWith("#")) continue;
|
|
178
|
+
const [k, ...rest] = line.split("=");
|
|
179
|
+
if (k.trim() === "SOUNDCLOUD_ACCESS_TOKEN") {
|
|
180
|
+
this.accessToken = rest.join("=").trim();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
226
183
|
}
|
|
227
|
-
return cfg;
|
|
228
184
|
}
|
|
229
185
|
|
|
230
186
|
saveToken(token: string): void {
|
|
@@ -251,7 +207,10 @@ class Config {
|
|
|
251
207
|
class SoundCloudAPI {
|
|
252
208
|
calls = 0;
|
|
253
209
|
|
|
254
|
-
constructor(
|
|
210
|
+
constructor(
|
|
211
|
+
private config: Config,
|
|
212
|
+
private log: (...args: any[]) => void
|
|
213
|
+
) {}
|
|
255
214
|
|
|
256
215
|
private checkBackoff(): number | null {
|
|
257
216
|
const data = readJson<{ last_fail?: number; fail_count?: number }>(
|
|
@@ -288,7 +247,7 @@ class SoundCloudAPI {
|
|
|
288
247
|
|
|
289
248
|
const remaining = this.checkBackoff();
|
|
290
249
|
if (remaining) {
|
|
291
|
-
|
|
250
|
+
this.log(`Token refresh in backoff (${remaining}s remaining)`);
|
|
292
251
|
return false;
|
|
293
252
|
}
|
|
294
253
|
|
|
@@ -306,25 +265,24 @@ class SoundCloudAPI {
|
|
|
306
265
|
});
|
|
307
266
|
if (resp.status === 429) {
|
|
308
267
|
this.setBackoff();
|
|
309
|
-
|
|
268
|
+
this.log("Token refresh rate limited (429)");
|
|
310
269
|
return false;
|
|
311
270
|
}
|
|
312
271
|
if (!resp.ok) {
|
|
313
|
-
|
|
272
|
+
this.log(`Token refresh failed: ${resp.status}`);
|
|
314
273
|
return false;
|
|
315
274
|
}
|
|
316
275
|
const result = (await resp.json()) as { access_token: string };
|
|
317
276
|
this.config.saveToken(result.access_token);
|
|
318
277
|
this.clearBackoff();
|
|
319
|
-
|
|
278
|
+
this.log("Token refreshed");
|
|
320
279
|
return true;
|
|
321
280
|
} catch (e) {
|
|
322
|
-
|
|
281
|
+
this.log(`Token refresh failed: ${e}`);
|
|
323
282
|
return false;
|
|
324
283
|
}
|
|
325
284
|
}
|
|
326
285
|
|
|
327
|
-
/** Make authenticated GET request. Accepts relative (/users/...) or full URLs. */
|
|
328
286
|
async get(
|
|
329
287
|
url: string,
|
|
330
288
|
params?: Record<string, string | number>,
|
|
@@ -364,29 +322,26 @@ class SoundCloudAPI {
|
|
|
364
322
|
}
|
|
365
323
|
}
|
|
366
324
|
if (!resp.ok) {
|
|
367
|
-
|
|
325
|
+
this.log(`API error ${resp.status}: ${fullUrl}`);
|
|
368
326
|
return null;
|
|
369
327
|
}
|
|
370
328
|
return (await resp.json()) as Record<string, any>;
|
|
371
329
|
} catch (e) {
|
|
372
|
-
|
|
330
|
+
this.log(`API error: ${e}`);
|
|
373
331
|
return null;
|
|
374
332
|
}
|
|
375
333
|
}
|
|
376
334
|
|
|
377
|
-
/** Resolve username to user object. */
|
|
378
335
|
resolve(username: string) {
|
|
379
336
|
return this.get("/resolve", {
|
|
380
337
|
url: `https://soundcloud.com/${username}`,
|
|
381
338
|
});
|
|
382
339
|
}
|
|
383
340
|
|
|
384
|
-
/** Get user profile by ID (includes followers_count). */
|
|
385
341
|
getUser(userId: number) {
|
|
386
342
|
return this.get(`/users/${userId}`);
|
|
387
343
|
}
|
|
388
344
|
|
|
389
|
-
/** Get a user's tracks. Response includes play/like/repost counts per track. */
|
|
390
345
|
async getTracks(userId: number, limit = 20): Promise<Record<string, any>[]> {
|
|
391
346
|
const data = await this.get(`/users/${userId}/tracks`, {
|
|
392
347
|
limit,
|
|
@@ -397,7 +352,6 @@ class SoundCloudAPI {
|
|
|
397
352
|
return Array.isArray(collection) ? collection : [];
|
|
398
353
|
}
|
|
399
354
|
|
|
400
|
-
/** Get users who liked a specific track. */
|
|
401
355
|
async getTrackLikers(
|
|
402
356
|
trackId: number,
|
|
403
357
|
limit = MAX_LIKERS_PER_TRACK
|
|
@@ -423,7 +377,6 @@ class SoundCloudAPI {
|
|
|
423
377
|
return likers;
|
|
424
378
|
}
|
|
425
379
|
|
|
426
|
-
/** Paginate through all followers. Expensive - only call when follower count changes. */
|
|
427
380
|
async getFollowersPaginated(
|
|
428
381
|
userId: number
|
|
429
382
|
): Promise<Record<string, UserInfo>> {
|
|
@@ -449,7 +402,7 @@ class SoundCloudAPI {
|
|
|
449
402
|
|
|
450
403
|
const nextHref = data.next_href;
|
|
451
404
|
if (nextHref && nextHref !== nextUrl) {
|
|
452
|
-
nextUrl = nextHref;
|
|
405
|
+
nextUrl = nextHref;
|
|
453
406
|
params = undefined;
|
|
454
407
|
} else {
|
|
455
408
|
break;
|
|
@@ -466,7 +419,9 @@ class AccountWatcher {
|
|
|
466
419
|
|
|
467
420
|
constructor(
|
|
468
421
|
private api: SoundCloudAPI,
|
|
469
|
-
private config: Config
|
|
422
|
+
private config: Config,
|
|
423
|
+
private myTracksLimit: number,
|
|
424
|
+
private log: (...args: any[]) => void
|
|
470
425
|
) {
|
|
471
426
|
const defaults: AccountState = {
|
|
472
427
|
my_account: null,
|
|
@@ -484,16 +439,9 @@ class AccountWatcher {
|
|
|
484
439
|
writeJson(ACCOUNT_DATA, this.data);
|
|
485
440
|
}
|
|
486
441
|
|
|
487
|
-
/**
|
|
488
|
-
* Run account check. Returns list of human-readable notification strings.
|
|
489
|
-
*
|
|
490
|
-
* API calls on a quiet day: 2 (profile + tracks)
|
|
491
|
-
* API calls on follower change: 2 + ceil(followers/200) + tracks_with_new_likes
|
|
492
|
-
*/
|
|
493
442
|
async check(): Promise<string[]> {
|
|
494
443
|
const notifications: string[] = [];
|
|
495
444
|
|
|
496
|
-
// Resolve account on first run
|
|
497
445
|
if (!this.data.my_account) {
|
|
498
446
|
const user = await this.api.resolve(this.config.myUsername);
|
|
499
447
|
if (!user) return ["Failed to resolve SoundCloud user"];
|
|
@@ -505,29 +453,26 @@ class AccountWatcher {
|
|
|
505
453
|
|
|
506
454
|
const userId = this.data.my_account.user_id;
|
|
507
455
|
|
|
508
|
-
// Fetch profile to check follower count (1 API call)
|
|
509
456
|
const profile = await this.api.getUser(userId);
|
|
510
457
|
if (!profile) {
|
|
511
|
-
|
|
512
|
-
return notifications;
|
|
458
|
+
this.log("Failed to fetch profile, skipping account check");
|
|
459
|
+
return notifications;
|
|
513
460
|
}
|
|
514
461
|
|
|
515
462
|
const currentCount = num(profile.followers_count);
|
|
516
463
|
const storedCount = this.data.follower_count;
|
|
517
464
|
|
|
518
|
-
// Only paginate full follower list if the count actually changed
|
|
519
465
|
if (currentCount !== storedCount || !Object.keys(this.data.my_followers).length) {
|
|
520
|
-
|
|
466
|
+
this.log(
|
|
521
467
|
`Follower count changed: ${storedCount} -> ${currentCount}, fetching list...`
|
|
522
468
|
);
|
|
523
469
|
const currentFollowers = await this.api.getFollowersPaginated(userId);
|
|
524
470
|
|
|
525
471
|
if (!Object.keys(currentFollowers).length && storedCount > 0) {
|
|
526
|
-
|
|
472
|
+
this.log("API returned empty followers, skipping comparison");
|
|
527
473
|
} else {
|
|
528
474
|
const stored = this.data.my_followers;
|
|
529
475
|
|
|
530
|
-
// Skip diff on first run (everything would show as "new")
|
|
531
476
|
if (Object.keys(stored).length) {
|
|
532
477
|
const newFollowers = Object.entries(currentFollowers)
|
|
533
478
|
.filter(([uid]) => !stored[uid])
|
|
@@ -555,11 +500,10 @@ class AccountWatcher {
|
|
|
555
500
|
this.data.follower_count = currentCount;
|
|
556
501
|
}
|
|
557
502
|
} else {
|
|
558
|
-
|
|
503
|
+
this.log(`Follower count unchanged (${currentCount}), skipping pagination`);
|
|
559
504
|
}
|
|
560
505
|
|
|
561
|
-
|
|
562
|
-
const tracks = await this.api.getTracks(userId, MY_TRACKS_LIMIT);
|
|
506
|
+
const tracks = await this.api.getTracks(userId, this.myTracksLimit);
|
|
563
507
|
if (tracks.length) {
|
|
564
508
|
const prevMap = new Map(this.data.track_stats.map((s) => [s.track_id, s]));
|
|
565
509
|
const newStats: TrackStats[] = [];
|
|
@@ -584,7 +528,6 @@ class AccountWatcher {
|
|
|
584
528
|
const prevLikes = prev.likes;
|
|
585
529
|
const prevLikers = prev.likers ?? {};
|
|
586
530
|
|
|
587
|
-
// Only fetch liker list if like count changed (or never seeded)
|
|
588
531
|
const needsLikerFetch =
|
|
589
532
|
currentLikes !== prevLikes ||
|
|
590
533
|
(currentLikes > 0 && !Object.keys(prevLikers).length);
|
|
@@ -611,7 +554,6 @@ class AccountWatcher {
|
|
|
611
554
|
notifications.push(`${names} unliked '${title}'`);
|
|
612
555
|
}
|
|
613
556
|
} else {
|
|
614
|
-
// No change - carry forward previous liker data
|
|
615
557
|
stats.likers = prevLikers;
|
|
616
558
|
}
|
|
617
559
|
|
|
@@ -622,7 +564,6 @@ class AccountWatcher {
|
|
|
622
564
|
);
|
|
623
565
|
}
|
|
624
566
|
} else {
|
|
625
|
-
// First time seeing this track - seed likers without notifying
|
|
626
567
|
stats.likers = await this.api.getTrackLikers(trackId);
|
|
627
568
|
}
|
|
628
569
|
|
|
@@ -631,7 +572,7 @@ class AccountWatcher {
|
|
|
631
572
|
|
|
632
573
|
this.data.track_stats = newStats;
|
|
633
574
|
} else {
|
|
634
|
-
|
|
575
|
+
this.log("Failed to fetch tracks, keeping previous stats");
|
|
635
576
|
}
|
|
636
577
|
|
|
637
578
|
this.save();
|
|
@@ -644,7 +585,11 @@ class AccountWatcher {
|
|
|
644
585
|
class ArtistTracker {
|
|
645
586
|
data: ArtistsState;
|
|
646
587
|
|
|
647
|
-
constructor(
|
|
588
|
+
constructor(
|
|
589
|
+
private api: SoundCloudAPI,
|
|
590
|
+
private dormantDays: number,
|
|
591
|
+
private log: (...args: any[]) => void
|
|
592
|
+
) {
|
|
648
593
|
this.data = readJson<ArtistsState>(ARTISTS_DATA, {
|
|
649
594
|
artists: {},
|
|
650
595
|
updated_at: null,
|
|
@@ -658,7 +603,7 @@ class ArtistTracker {
|
|
|
658
603
|
|
|
659
604
|
private isDormant(artist: ArtistData): boolean {
|
|
660
605
|
const days = daysSince(artist.last_upload ?? "");
|
|
661
|
-
return days !== null && days >
|
|
606
|
+
return days !== null && days > this.dormantDays;
|
|
662
607
|
}
|
|
663
608
|
|
|
664
609
|
private shouldSkip(artist: ArtistData): boolean {
|
|
@@ -667,10 +612,6 @@ class ArtistTracker {
|
|
|
667
612
|
return days !== null && days < DORMANT_CHECK_INTERVAL_DAYS;
|
|
668
613
|
}
|
|
669
614
|
|
|
670
|
-
/**
|
|
671
|
-
* Check all tracked artists for new releases.
|
|
672
|
-
* API calls: 1 per active artist, 1 per dormant artist due for check, 0 for skipped.
|
|
673
|
-
*/
|
|
674
615
|
async checkReleases(): Promise<ReleaseNotification[]> {
|
|
675
616
|
const notifications: ReleaseNotification[] = [];
|
|
676
617
|
let checked = 0;
|
|
@@ -715,7 +656,6 @@ class ArtistTracker {
|
|
|
715
656
|
}
|
|
716
657
|
}
|
|
717
658
|
|
|
718
|
-
// Prune old track IDs to prevent unbounded growth
|
|
719
659
|
const ids = this.data.artists[username].known_track_ids ?? [];
|
|
720
660
|
if (ids.length > MAX_KNOWN_TRACKS) {
|
|
721
661
|
this.data.artists[username].known_track_ids = ids.slice(-MAX_KNOWN_TRACKS);
|
|
@@ -725,7 +665,7 @@ class ArtistTracker {
|
|
|
725
665
|
const dormantCount = Object.values(this.data.artists).filter((a) =>
|
|
726
666
|
this.isDormant(a)
|
|
727
667
|
).length;
|
|
728
|
-
|
|
668
|
+
this.log(
|
|
729
669
|
`Checked ${checked} artists, skipped ${skipped} dormant, ${dormantCount} total dormant`
|
|
730
670
|
);
|
|
731
671
|
|
|
@@ -733,7 +673,6 @@ class ArtistTracker {
|
|
|
733
673
|
return notifications;
|
|
734
674
|
}
|
|
735
675
|
|
|
736
|
-
/** Add an artist to tracking. Seeds known tracks to avoid false notifications. */
|
|
737
676
|
async add(username: string): Promise<string> {
|
|
738
677
|
const user = await this.api.resolve(username);
|
|
739
678
|
if (!user) return `Could not find user: ${username}`;
|
|
@@ -780,7 +719,6 @@ class ArtistTracker {
|
|
|
780
719
|
return `Added ${user.full_name || username} (${followers.toLocaleString()} followers, ${tracks.length} tracks)`;
|
|
781
720
|
}
|
|
782
721
|
|
|
783
|
-
/** Remove an artist from tracking. */
|
|
784
722
|
remove(username: string): string {
|
|
785
723
|
const key = username.toLowerCase();
|
|
786
724
|
for (const [k, artist] of Object.entries(this.data.artists)) {
|
|
@@ -794,161 +732,153 @@ class ArtistTracker {
|
|
|
794
732
|
return `Artist '${username}' not found`;
|
|
795
733
|
}
|
|
796
734
|
|
|
797
|
-
|
|
798
|
-
list(): void {
|
|
735
|
+
list(): string {
|
|
799
736
|
const artists = Object.values(this.data.artists).sort(
|
|
800
737
|
(a, b) => (b.followers ?? 0) - (a.followers ?? 0)
|
|
801
738
|
);
|
|
802
|
-
|
|
739
|
+
const lines: string[] = [];
|
|
740
|
+
lines.push(`\n=== Tracked Artists (${artists.length}) ===\n`);
|
|
803
741
|
for (const a of artists) {
|
|
804
742
|
const dormant = this.isDormant(a);
|
|
805
743
|
const status = dormant ? " [DORMANT]" : "";
|
|
806
|
-
|
|
807
|
-
|
|
744
|
+
lines.push(`${a.display_name} (@${a.username})${status}`);
|
|
745
|
+
lines.push(
|
|
808
746
|
` ${(a.followers ?? 0).toLocaleString()} followers | ${a.track_count ?? 0} tracks`
|
|
809
747
|
);
|
|
810
|
-
if (a.last_upload)
|
|
811
|
-
|
|
748
|
+
if (a.last_upload) lines.push(` Last upload: ${a.last_upload.slice(0, 10)}`);
|
|
749
|
+
lines.push("");
|
|
812
750
|
}
|
|
751
|
+
return lines.join("\n");
|
|
813
752
|
}
|
|
814
753
|
}
|
|
815
754
|
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
* Send notification via gateway.
|
|
820
|
-
* To use a different system (email, Discord webhook, Pushover, etc.),
|
|
821
|
-
* replace the body of this function with your preferred delivery mechanism.
|
|
822
|
-
*/
|
|
823
|
-
async function sendNotification(message: string): Promise<boolean> {
|
|
824
|
-
if (!NOTIFICATIONS_ENABLED) return false;
|
|
825
|
-
|
|
826
|
-
let token: string;
|
|
827
|
-
let port: number;
|
|
828
|
-
try {
|
|
829
|
-
const cfg = JSON.parse(fs.readFileSync(GATEWAY_CONFIG, "utf-8"));
|
|
830
|
-
token = cfg.gateway.auth.token;
|
|
831
|
-
port = cfg.gateway.port ?? GATEWAY_PORT_DEFAULT;
|
|
832
|
-
} catch (e) {
|
|
833
|
-
console.log(`Gateway config error: ${e}`);
|
|
834
|
-
return false;
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
try {
|
|
838
|
-
const resp = await fetch(`http://127.0.0.1:${port}${GATEWAY_ENDPOINT}`, {
|
|
839
|
-
method: "POST",
|
|
840
|
-
headers: {
|
|
841
|
-
"Content-Type": "application/json",
|
|
842
|
-
Authorization: `Bearer ${token}`,
|
|
843
|
-
},
|
|
844
|
-
body: JSON.stringify({
|
|
845
|
-
tool: GATEWAY_TOOL_NAME,
|
|
846
|
-
args: { sessionKey: GATEWAY_SESSION_KEY, message },
|
|
847
|
-
}),
|
|
848
|
-
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
849
|
-
});
|
|
850
|
-
return resp.ok;
|
|
851
|
-
} catch (e) {
|
|
852
|
-
console.log(`Notification failed: ${e}`);
|
|
853
|
-
return false;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// -- Commands -----------------------------------------------------------------
|
|
858
|
-
|
|
859
|
-
async function runFullCheck(
|
|
860
|
-
api: SoundCloudAPI,
|
|
861
|
-
config: Config
|
|
862
|
-
): Promise<[string[], ReleaseNotification[]]> {
|
|
863
|
-
const account = new AccountWatcher(api, config);
|
|
864
|
-
const tracker = new ArtistTracker(api);
|
|
865
|
-
return [await account.check(), await tracker.checkReleases()];
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async function main(): Promise<void> {
|
|
869
|
-
const config = Config.load();
|
|
755
|
+
// =============================================================================
|
|
756
|
+
// EXPORTED FACADE
|
|
757
|
+
// =============================================================================
|
|
870
758
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
console.log
|
|
880
|
-
|
|
759
|
+
export class SoundCloudWatcher {
|
|
760
|
+
private config: Config;
|
|
761
|
+
private api: SoundCloudAPI;
|
|
762
|
+
private myTracksLimit: number;
|
|
763
|
+
private dormantDays: number;
|
|
764
|
+
private log: (...args: any[]) => void;
|
|
765
|
+
|
|
766
|
+
constructor(opts: SoundCloudWatcherConfig) {
|
|
767
|
+
this.log = opts.logger ?? console.log;
|
|
768
|
+
this.myTracksLimit = opts.myTracksLimit ?? 10;
|
|
769
|
+
this.dormantDays = opts.dormantDays ?? 90;
|
|
770
|
+
this.config = new Config(opts.clientId, opts.clientSecret, opts.username);
|
|
771
|
+
this.api = new SoundCloudAPI(this.config, this.log);
|
|
881
772
|
}
|
|
882
773
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
console.log("Failed to get access token");
|
|
888
|
-
return;
|
|
774
|
+
private async ensureToken(): Promise<string | null> {
|
|
775
|
+
if (this.config.accessToken) return null;
|
|
776
|
+
if (!(await this.api.refreshToken())) {
|
|
777
|
+
return "Failed to get access token. Check your clientId and clientSecret.";
|
|
889
778
|
}
|
|
779
|
+
return null;
|
|
890
780
|
}
|
|
891
781
|
|
|
892
|
-
|
|
893
|
-
|
|
782
|
+
async status(): Promise<string> {
|
|
783
|
+
const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
|
|
784
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
894
785
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
console.log(" cron Silent mode - only sends notifications on updates");
|
|
902
|
-
console.log(" add <user> Add artist(s) to track");
|
|
903
|
-
console.log(" remove <user> Remove artist from tracking");
|
|
904
|
-
console.log(" list List all tracked artists");
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
if (cmd === "status") {
|
|
909
|
-
const account = new AccountWatcher(api, config);
|
|
910
|
-
const tracker = new ArtistTracker(api);
|
|
911
|
-
|
|
912
|
-
console.log("=== SoundCloud Watcher Status ===\n");
|
|
913
|
-
console.log(`Config: ${CONFIG_FILE}`);
|
|
914
|
-
console.log(
|
|
915
|
-
config.accessToken
|
|
916
|
-
? `Token: ...${config.accessToken.slice(-8)}`
|
|
786
|
+
const lines: string[] = [];
|
|
787
|
+
lines.push("=== SoundCloud Watcher Status ===\n");
|
|
788
|
+
lines.push(`Config: ${CONFIG_FILE}`);
|
|
789
|
+
lines.push(
|
|
790
|
+
this.config.accessToken
|
|
791
|
+
? `Token: ...${this.config.accessToken.slice(-8)}`
|
|
917
792
|
: "Token: None"
|
|
918
793
|
);
|
|
919
794
|
if (account.data.my_account) {
|
|
920
|
-
|
|
921
|
-
|
|
795
|
+
lines.push(`Account: @${account.data.my_account.username}`);
|
|
796
|
+
lines.push(
|
|
922
797
|
`Followers: ${account.data.follower_count || Object.keys(account.data.my_followers).length}`
|
|
923
798
|
);
|
|
924
799
|
}
|
|
925
800
|
const total = Object.keys(tracker.data.artists).length;
|
|
926
801
|
const dormant = Object.values(tracker.data.artists).filter(
|
|
927
|
-
(a) => (daysSince(a.last_upload ?? "") ?? 0) >
|
|
802
|
+
(a) => (daysSince(a.last_upload ?? "") ?? 0) > this.dormantDays
|
|
928
803
|
).length;
|
|
929
|
-
|
|
804
|
+
lines.push(
|
|
930
805
|
`Tracked artists: ${total} (${total - dormant} active, ${dormant} dormant)`
|
|
931
806
|
);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
807
|
+
lines.push(`Last check: ${account.data.last_check ?? "Never"}`);
|
|
808
|
+
return lines.join("\n");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async check(): Promise<string> {
|
|
812
|
+
const tokenErr = await this.ensureToken();
|
|
813
|
+
if (tokenErr) return tokenErr;
|
|
814
|
+
|
|
815
|
+
const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
|
|
816
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
817
|
+
|
|
818
|
+
const [accountNotifs, releases] = await Promise.all([
|
|
819
|
+
account.check(),
|
|
820
|
+
tracker.checkReleases(),
|
|
821
|
+
]);
|
|
822
|
+
|
|
823
|
+
const lines: string[] = [];
|
|
824
|
+
lines.push(`[${utcnow()}] Full check complete\n`);
|
|
825
|
+
|
|
826
|
+
lines.push("--- Account ---");
|
|
827
|
+
for (const n of accountNotifs) lines.push(` ${n}`);
|
|
828
|
+
if (!accountNotifs.length) lines.push(" No updates");
|
|
829
|
+
|
|
830
|
+
lines.push("\n--- Artist Releases ---");
|
|
831
|
+
for (const r of releases) lines.push(` ${r.artist}: ${r.title}`);
|
|
832
|
+
if (!releases.length) lines.push(" No new releases");
|
|
938
833
|
|
|
939
|
-
|
|
834
|
+
lines.push(`\nAPI calls: ${this.api.calls}`);
|
|
835
|
+
return lines.join("\n");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async addArtist(username: string): Promise<string> {
|
|
839
|
+
const tokenErr = await this.ensureToken();
|
|
840
|
+
if (tokenErr) return tokenErr;
|
|
841
|
+
|
|
842
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
843
|
+
return tracker.add(username);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async addArtists(usernames: string[]): Promise<string> {
|
|
847
|
+
const tokenErr = await this.ensureToken();
|
|
848
|
+
if (tokenErr) return tokenErr;
|
|
849
|
+
|
|
850
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
851
|
+
const results: string[] = [];
|
|
852
|
+
for (const username of usernames) {
|
|
853
|
+
results.push(await tracker.add(username));
|
|
854
|
+
}
|
|
855
|
+
return results.join("\n");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async removeArtist(username: string): Promise<string> {
|
|
859
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
860
|
+
return tracker.remove(username);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async listArtists(): Promise<string> {
|
|
864
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
865
|
+
return tracker.list();
|
|
866
|
+
}
|
|
940
867
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
if (
|
|
868
|
+
async runCron(): Promise<string | null> {
|
|
869
|
+
const tokenErr = await this.ensureToken();
|
|
870
|
+
if (tokenErr) {
|
|
871
|
+
this.log(tokenErr);
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
944
874
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (!releases.length) console.log(" No new releases");
|
|
875
|
+
const account = new AccountWatcher(this.api, this.config, this.myTracksLimit, this.log);
|
|
876
|
+
const tracker = new ArtistTracker(this.api, this.dormantDays, this.log);
|
|
948
877
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
878
|
+
const [accountNotifs, releases] = await Promise.all([
|
|
879
|
+
account.check(),
|
|
880
|
+
tracker.checkReleases(),
|
|
881
|
+
]);
|
|
952
882
|
|
|
953
883
|
const lines: string[] = [];
|
|
954
884
|
if (accountNotifs.length) {
|
|
@@ -966,33 +896,9 @@ async function main(): Promise<void> {
|
|
|
966
896
|
}
|
|
967
897
|
|
|
968
898
|
if (lines.length) {
|
|
969
|
-
|
|
970
|
-
if (NOTIFICATIONS_ENABLED) {
|
|
971
|
-
await sendNotification(message);
|
|
972
|
-
} else {
|
|
973
|
-
console.log(message);
|
|
974
|
-
}
|
|
899
|
+
return "SoundCloud updates:\n\n" + lines.join("\n");
|
|
975
900
|
}
|
|
976
901
|
|
|
977
|
-
|
|
978
|
-
} else if (cmd === "add" && args.length > 1) {
|
|
979
|
-
const tracker = new ArtistTracker(api);
|
|
980
|
-
for (const username of args.slice(1)) {
|
|
981
|
-
console.log(await tracker.add(username));
|
|
982
|
-
}
|
|
983
|
-
} else if (cmd === "remove" && args.length > 1) {
|
|
984
|
-
const tracker = new ArtistTracker(api);
|
|
985
|
-
console.log(tracker.remove(args[1]));
|
|
986
|
-
} else if (cmd === "list") {
|
|
987
|
-
const tracker = new ArtistTracker(api);
|
|
988
|
-
tracker.list();
|
|
989
|
-
} else {
|
|
990
|
-
console.log(`Unknown command: ${cmd}`);
|
|
991
|
-
console.log("Run without arguments for usage info.");
|
|
902
|
+
return null;
|
|
992
903
|
}
|
|
993
904
|
}
|
|
994
|
-
|
|
995
|
-
main().catch((e) => {
|
|
996
|
-
console.error(`Fatal error: ${e}`);
|
|
997
|
-
process.exit(1);
|
|
998
|
-
});
|