@akilles/soundcloud-watcher 2.0.5 → 2.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.
- package/README.md +3 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -1
- package/soundcloud_watcher.ts +183 -39
package/README.md
CHANGED
|
@@ -48,6 +48,9 @@ Add your credentials:
|
|
|
48
48
|
SOUNDCLOUD_CLIENT_ID=your_client_id
|
|
49
49
|
SOUNDCLOUD_CLIENT_SECRET=your_client_secret
|
|
50
50
|
MY_USERNAME=your_soundcloud_username
|
|
51
|
+
|
|
52
|
+
# Optional settings
|
|
53
|
+
INCLUDE_LINKS=true # Include URLs in notifications (default: true)
|
|
51
54
|
```
|
|
52
55
|
|
|
53
56
|
### 4. Restart & Verify
|
package/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ interface PluginConfig {
|
|
|
10
10
|
checkIntervalHours: number;
|
|
11
11
|
myTracksLimit: number;
|
|
12
12
|
dormantDays: number;
|
|
13
|
+
includeLinks: boolean;
|
|
13
14
|
sessionKey?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -34,6 +35,7 @@ function loadConfig(): PluginConfig | null {
|
|
|
34
35
|
checkIntervalHours: 6,
|
|
35
36
|
myTracksLimit: 10,
|
|
36
37
|
dormantDays: 90,
|
|
38
|
+
includeLinks: env.INCLUDE_LINKS !== 'false', // Default: true
|
|
37
39
|
sessionKey: 'agent:main:main',
|
|
38
40
|
};
|
|
39
41
|
}
|
|
@@ -57,6 +59,7 @@ export default function register(api: any) {
|
|
|
57
59
|
username: config.username,
|
|
58
60
|
myTracksLimit: config.myTracksLimit,
|
|
59
61
|
dormantDays: config.dormantDays,
|
|
62
|
+
includeLinks: config.includeLinks,
|
|
60
63
|
logger: (...args: any[]) => logger.debug?.(...args) || console.log(...args),
|
|
61
64
|
});
|
|
62
65
|
return watcher;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akilles/soundcloud-watcher",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "OpenClaw plugin to monitor SoundCloud account and track artist releases",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"openclaw": {
|
|
@@ -44,5 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=22.0.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"typescript": "^5.9.3"
|
|
47
50
|
}
|
|
48
51
|
}
|
package/soundcloud_watcher.ts
CHANGED
|
@@ -25,6 +25,7 @@ const CONFIG_FILE = path.join(OPENCLAW_DIR, "secrets", "soundcloud.env");
|
|
|
25
25
|
const ACCOUNT_DATA = path.join(OPENCLAW_DIR, "data", "soundcloud_tracking.json");
|
|
26
26
|
const ARTISTS_DATA = path.join(OPENCLAW_DIR, "data", "artists.json");
|
|
27
27
|
const BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_backoff.json");
|
|
28
|
+
const TOKEN_BACKOFF_FILE = path.join(OPENCLAW_DIR, "soundcloud_token_backoff.json");
|
|
28
29
|
|
|
29
30
|
// =============================================================================
|
|
30
31
|
// TUNING
|
|
@@ -40,6 +41,13 @@ const FOLLOWERS_PAGE_SIZE = 200;
|
|
|
40
41
|
const BACKOFF_BASE_SECONDS = 300;
|
|
41
42
|
const BACKOFF_MAX_SECONDS = 7200;
|
|
42
43
|
|
|
44
|
+
// Token refresh has lower backoff - it's critical for operation
|
|
45
|
+
const TOKEN_BACKOFF_BASE_SECONDS = 60;
|
|
46
|
+
const TOKEN_BACKOFF_MAX_SECONDS = 300; // 5 min max, not 2 hours
|
|
47
|
+
|
|
48
|
+
// Proactive refresh buffer (refresh 5 min before expiry)
|
|
49
|
+
const TOKEN_REFRESH_BUFFER_SECONDS = 300;
|
|
50
|
+
|
|
43
51
|
const API_TIMEOUT_MS = 15_000;
|
|
44
52
|
|
|
45
53
|
// =============================================================================
|
|
@@ -56,12 +64,24 @@ export interface SoundCloudWatcherConfig {
|
|
|
56
64
|
username: string;
|
|
57
65
|
myTracksLimit?: number;
|
|
58
66
|
dormantDays?: number;
|
|
67
|
+
includeLinks?: boolean; // Include URLs in notifications (default: true)
|
|
59
68
|
logger?: (...args: any[]) => void;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
interface UserInfo {
|
|
63
72
|
username: string;
|
|
64
73
|
display_name: string;
|
|
74
|
+
permalink_url?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface FollowerNotification {
|
|
78
|
+
type: 'new' | 'lost';
|
|
79
|
+
users: UserInfo[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface AccountNotifications {
|
|
83
|
+
followers: FollowerNotification[];
|
|
84
|
+
engagement: string[]; // likes, reposts, etc.
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
interface TrackStats {
|
|
@@ -215,6 +235,7 @@ class SoundCloudAPI {
|
|
|
215
235
|
private log: (...args: any[]) => void
|
|
216
236
|
) {}
|
|
217
237
|
|
|
238
|
+
// -- API rate limit backoff (for 429s on regular API calls) --
|
|
218
239
|
private checkBackoff(): number | null {
|
|
219
240
|
const data = readJson<{ last_fail?: number; fail_count?: number }>(
|
|
220
241
|
BACKOFF_FILE,
|
|
@@ -245,16 +266,75 @@ class SoundCloudAPI {
|
|
|
245
266
|
if (fs.existsSync(BACKOFF_FILE)) fs.unlinkSync(BACKOFF_FILE);
|
|
246
267
|
}
|
|
247
268
|
|
|
248
|
-
|
|
269
|
+
// -- Token refresh backoff (separate, shorter max) --
|
|
270
|
+
private checkTokenBackoff(): number | null {
|
|
271
|
+
const data = readJson<{ last_fail?: number; fail_count?: number }>(
|
|
272
|
+
TOKEN_BACKOFF_FILE,
|
|
273
|
+
{}
|
|
274
|
+
);
|
|
275
|
+
if (!data.last_fail) return null;
|
|
276
|
+
const elapsed = Date.now() / 1000 - data.last_fail;
|
|
277
|
+
const backoff = Math.min(
|
|
278
|
+
TOKEN_BACKOFF_BASE_SECONDS * 2 ** (data.fail_count ?? 0),
|
|
279
|
+
TOKEN_BACKOFF_MAX_SECONDS
|
|
280
|
+
);
|
|
281
|
+
return elapsed < backoff ? Math.floor(backoff - elapsed) : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private setTokenBackoff(): void {
|
|
285
|
+
try {
|
|
286
|
+
const data = readJson<{ fail_count?: number }>(TOKEN_BACKOFF_FILE, {});
|
|
287
|
+
writeJson(TOKEN_BACKOFF_FILE, {
|
|
288
|
+
fail_count: (data.fail_count ?? 0) + 1,
|
|
289
|
+
last_fail: Date.now() / 1000,
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
/* best effort */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private clearTokenBackoff(): void {
|
|
297
|
+
if (fs.existsSync(TOKEN_BACKOFF_FILE)) fs.unlinkSync(TOKEN_BACKOFF_FILE);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// -- JWT helpers for proactive refresh --
|
|
301
|
+
private getTokenExpiry(): number | null {
|
|
302
|
+
const token = this.config.accessToken;
|
|
303
|
+
if (!token) return null;
|
|
304
|
+
try {
|
|
305
|
+
const parts = token.split(".");
|
|
306
|
+
if (parts.length !== 3) return null;
|
|
307
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
|
308
|
+
return payload.exp ?? null;
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private isTokenExpiringSoon(): boolean {
|
|
315
|
+
const exp = this.getTokenExpiry();
|
|
316
|
+
if (!exp) return true; // No token or can't decode = refresh
|
|
317
|
+
const now = Date.now() / 1000;
|
|
318
|
+
return now > exp - TOKEN_REFRESH_BUFFER_SECONDS;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// -- Token refresh (with separate backoff, proactive check) --
|
|
322
|
+
async refreshToken(force = false): Promise<boolean> {
|
|
249
323
|
if (!this.config.clientId || !this.config.clientSecret) return false;
|
|
250
324
|
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
325
|
+
// Skip if token is still valid (unless forced, e.g., on 401)
|
|
326
|
+
if (!force && !this.isTokenExpiringSoon()) {
|
|
327
|
+
return true; // Token still good
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const remaining = this.checkTokenBackoff();
|
|
331
|
+
if (remaining && !force) {
|
|
332
|
+
this.log(`Token refresh in backoff (${remaining}s remaining), skipping`);
|
|
254
333
|
return false;
|
|
255
334
|
}
|
|
256
335
|
|
|
257
336
|
try {
|
|
337
|
+
this.log("Refreshing token...");
|
|
258
338
|
const body = new URLSearchParams({
|
|
259
339
|
grant_type: "client_credentials",
|
|
260
340
|
client_id: this.config.clientId,
|
|
@@ -267,25 +347,35 @@ class SoundCloudAPI {
|
|
|
267
347
|
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
268
348
|
});
|
|
269
349
|
if (resp.status === 429) {
|
|
270
|
-
this.
|
|
350
|
+
this.setTokenBackoff();
|
|
271
351
|
this.log("Token refresh rate limited (429)");
|
|
272
352
|
return false;
|
|
273
353
|
}
|
|
274
354
|
if (!resp.ok) {
|
|
355
|
+
this.setTokenBackoff();
|
|
275
356
|
this.log(`Token refresh failed: ${resp.status}`);
|
|
276
357
|
return false;
|
|
277
358
|
}
|
|
278
359
|
const result = (await resp.json()) as { access_token: string };
|
|
279
360
|
this.config.saveToken(result.access_token);
|
|
280
|
-
this.
|
|
281
|
-
this.log("Token refreshed");
|
|
361
|
+
this.clearTokenBackoff();
|
|
362
|
+
this.log("Token refreshed successfully");
|
|
282
363
|
return true;
|
|
283
364
|
} catch (e) {
|
|
284
|
-
this.
|
|
365
|
+
this.setTokenBackoff();
|
|
366
|
+
this.log(`Token refresh error: ${e}`);
|
|
285
367
|
return false;
|
|
286
368
|
}
|
|
287
369
|
}
|
|
288
370
|
|
|
371
|
+
// Proactive check - call before making API requests
|
|
372
|
+
async ensureValidToken(): Promise<boolean> {
|
|
373
|
+
if (this.isTokenExpiringSoon()) {
|
|
374
|
+
return this.refreshToken(false);
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
289
379
|
async get(
|
|
290
380
|
url: string,
|
|
291
381
|
params?: Record<string, string | number>,
|
|
@@ -320,7 +410,8 @@ class SoundCloudAPI {
|
|
|
320
410
|
});
|
|
321
411
|
|
|
322
412
|
if (resp.status === 401 && retry) {
|
|
323
|
-
|
|
413
|
+
this.log("Got 401, forcing token refresh...");
|
|
414
|
+
if (await this.refreshToken(true)) { // force=true bypasses backoff
|
|
324
415
|
return this.get(url, params, false);
|
|
325
416
|
}
|
|
326
417
|
}
|
|
@@ -396,9 +487,11 @@ class SoundCloudAPI {
|
|
|
396
487
|
|
|
397
488
|
for (const f of data.collection ?? []) {
|
|
398
489
|
if (f && typeof f === "object" && "id" in f) {
|
|
490
|
+
const permalink = f.permalink ?? f.username ?? "unknown";
|
|
399
491
|
followers[String(f.id)] = {
|
|
400
|
-
username:
|
|
492
|
+
username: permalink,
|
|
401
493
|
display_name: f.full_name ?? f.username ?? "unknown",
|
|
494
|
+
permalink_url: f.permalink_url ?? `https://soundcloud.com/${permalink}`,
|
|
402
495
|
};
|
|
403
496
|
}
|
|
404
497
|
}
|
|
@@ -442,12 +535,12 @@ class AccountWatcher {
|
|
|
442
535
|
writeJson(ACCOUNT_DATA, this.data);
|
|
443
536
|
}
|
|
444
537
|
|
|
445
|
-
async check(): Promise<
|
|
446
|
-
const
|
|
538
|
+
async check(): Promise<AccountNotifications> {
|
|
539
|
+
const result: AccountNotifications = { followers: [], engagement: [] };
|
|
447
540
|
|
|
448
541
|
if (!this.data.my_account) {
|
|
449
542
|
const user = await this.api.resolve(this.config.myUsername);
|
|
450
|
-
if (!user) return
|
|
543
|
+
if (!user) return result;
|
|
451
544
|
this.data.my_account = {
|
|
452
545
|
user_id: user.id,
|
|
453
546
|
username: user.permalink ?? this.config.myUsername,
|
|
@@ -459,7 +552,7 @@ class AccountWatcher {
|
|
|
459
552
|
const profile = await this.api.getUser(userId);
|
|
460
553
|
if (!profile) {
|
|
461
554
|
this.log("Failed to fetch profile, skipping account check");
|
|
462
|
-
return
|
|
555
|
+
return result;
|
|
463
556
|
}
|
|
464
557
|
|
|
465
558
|
const currentCount = num(profile.followers_count);
|
|
@@ -479,23 +572,16 @@ class AccountWatcher {
|
|
|
479
572
|
if (Object.keys(stored).length) {
|
|
480
573
|
const newFollowers = Object.entries(currentFollowers)
|
|
481
574
|
.filter(([uid]) => !stored[uid])
|
|
482
|
-
.map(([, f]) => f
|
|
575
|
+
.map(([, f]) => f);
|
|
483
576
|
const lostFollowers = Object.entries(stored)
|
|
484
577
|
.filter(([uid]) => !currentFollowers[uid])
|
|
485
|
-
.map(([, f]) => f
|
|
578
|
+
.map(([, f]) => f);
|
|
486
579
|
|
|
487
580
|
if (newFollowers.length) {
|
|
488
|
-
|
|
489
|
-
if (newFollowers.length > 3) names += ` +${newFollowers.length - 3} more`;
|
|
490
|
-
notifications.push(
|
|
491
|
-
`New follower${newFollowers.length > 1 ? "s" : ""}: **${names}**`
|
|
492
|
-
);
|
|
581
|
+
result.followers.push({ type: 'new', users: newFollowers });
|
|
493
582
|
}
|
|
494
583
|
if (lostFollowers.length) {
|
|
495
|
-
|
|
496
|
-
notifications.push(
|
|
497
|
-
`Lost follower${lostFollowers.length > 1 ? "s" : ""}: ${names}`
|
|
498
|
-
);
|
|
584
|
+
result.followers.push({ type: 'lost', users: lostFollowers });
|
|
499
585
|
}
|
|
500
586
|
}
|
|
501
587
|
|
|
@@ -550,11 +636,11 @@ class AccountWatcher {
|
|
|
550
636
|
let names = newLikerNames.slice(0, 3).join(", ");
|
|
551
637
|
if (newLikerNames.length > 3)
|
|
552
638
|
names += ` +${newLikerNames.length - 3} more`;
|
|
553
|
-
|
|
639
|
+
result.engagement.push(`**${names}** liked '${title}'`);
|
|
554
640
|
}
|
|
555
641
|
if (unlikerNames.length) {
|
|
556
642
|
const names = unlikerNames.slice(0, 3).join(", ");
|
|
557
|
-
|
|
643
|
+
result.engagement.push(`${names} unliked '${title}'`);
|
|
558
644
|
}
|
|
559
645
|
} else {
|
|
560
646
|
stats.likers = prevLikers;
|
|
@@ -562,7 +648,7 @@ class AccountWatcher {
|
|
|
562
648
|
|
|
563
649
|
const newReposts = currentReposts - (prev.reposts ?? 0);
|
|
564
650
|
if (newReposts > 0) {
|
|
565
|
-
|
|
651
|
+
result.engagement.push(
|
|
566
652
|
`'${title}' got ${newReposts} repost${newReposts > 1 ? "s" : ""}!`
|
|
567
653
|
);
|
|
568
654
|
}
|
|
@@ -579,7 +665,7 @@ class AccountWatcher {
|
|
|
579
665
|
}
|
|
580
666
|
|
|
581
667
|
this.save();
|
|
582
|
-
return
|
|
668
|
+
return result;
|
|
583
669
|
}
|
|
584
670
|
}
|
|
585
671
|
|
|
@@ -764,20 +850,22 @@ export class SoundCloudWatcher {
|
|
|
764
850
|
private api: SoundCloudAPI;
|
|
765
851
|
private myTracksLimit: number;
|
|
766
852
|
private dormantDays: number;
|
|
853
|
+
private includeLinks: boolean;
|
|
767
854
|
private log: (...args: any[]) => void;
|
|
768
855
|
|
|
769
856
|
constructor(opts: SoundCloudWatcherConfig) {
|
|
770
857
|
this.log = opts.logger ?? console.log;
|
|
771
858
|
this.myTracksLimit = opts.myTracksLimit ?? 10;
|
|
772
859
|
this.dormantDays = opts.dormantDays ?? 90;
|
|
860
|
+
this.includeLinks = opts.includeLinks ?? true; // Default: include links
|
|
773
861
|
this.config = new Config(opts.clientId, opts.clientSecret, opts.username);
|
|
774
862
|
this.api = new SoundCloudAPI(this.config, this.log);
|
|
775
863
|
}
|
|
776
864
|
|
|
777
865
|
private async ensureToken(): Promise<string | null> {
|
|
778
|
-
if
|
|
779
|
-
if (!(await this.api.
|
|
780
|
-
return "Failed to get access token. Check your clientId and clientSecret.";
|
|
866
|
+
// Proactive refresh: check if token exists AND is not expiring soon
|
|
867
|
+
if (!(await this.api.ensureValidToken())) {
|
|
868
|
+
return "Failed to get/refresh access token. Check your clientId and clientSecret.";
|
|
781
869
|
}
|
|
782
870
|
return null;
|
|
783
871
|
}
|
|
@@ -827,11 +915,24 @@ export class SoundCloudWatcher {
|
|
|
827
915
|
lines.push(`[${utcnow()}] Full check complete\n`);
|
|
828
916
|
|
|
829
917
|
lines.push("--- Account ---");
|
|
830
|
-
for (const
|
|
831
|
-
|
|
918
|
+
for (const fn of accountNotifs.followers) {
|
|
919
|
+
lines.push(...this.formatFollowerNotification(fn).map(l => ` ${l}`));
|
|
920
|
+
}
|
|
921
|
+
for (const e of accountNotifs.engagement) {
|
|
922
|
+
lines.push(` ${e}`);
|
|
923
|
+
}
|
|
924
|
+
if (!accountNotifs.followers.length && !accountNotifs.engagement.length) {
|
|
925
|
+
lines.push(" No updates");
|
|
926
|
+
}
|
|
832
927
|
|
|
833
928
|
lines.push("\n--- Artist Releases ---");
|
|
834
|
-
for (const r of releases)
|
|
929
|
+
for (const r of releases) {
|
|
930
|
+
if (this.includeLinks && r.url) {
|
|
931
|
+
lines.push(` ${r.artist}: ${r.title} - ${r.url}`);
|
|
932
|
+
} else {
|
|
933
|
+
lines.push(` ${r.artist}: ${r.title}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
835
936
|
if (!releases.length) lines.push(" No new releases");
|
|
836
937
|
|
|
837
938
|
lines.push(`\nAPI calls: ${this.api.calls}`);
|
|
@@ -868,6 +969,34 @@ export class SoundCloudWatcher {
|
|
|
868
969
|
return tracker.list();
|
|
869
970
|
}
|
|
870
971
|
|
|
972
|
+
private formatFollowerNotification(notif: FollowerNotification): string[] {
|
|
973
|
+
const lines: string[] = [];
|
|
974
|
+
const users = notif.users.slice(0, 5); // Max 5 users shown
|
|
975
|
+
const remaining = notif.users.length - users.length;
|
|
976
|
+
|
|
977
|
+
if (notif.type === 'new') {
|
|
978
|
+
lines.push(`New follower${notif.users.length > 1 ? 's' : ''}:`);
|
|
979
|
+
for (const u of users) {
|
|
980
|
+
if (this.includeLinks && u.permalink_url) {
|
|
981
|
+
lines.push(`- **${u.display_name}**: ${u.permalink_url}`);
|
|
982
|
+
} else {
|
|
983
|
+
lines.push(`- **${u.display_name}**`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
} else {
|
|
987
|
+
lines.push(`Lost follower${notif.users.length > 1 ? 's' : ''}:`);
|
|
988
|
+
for (const u of users) {
|
|
989
|
+
lines.push(`- ${u.display_name}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (remaining > 0) {
|
|
994
|
+
lines.push(` ...and ${remaining} more`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return lines;
|
|
998
|
+
}
|
|
999
|
+
|
|
871
1000
|
async runCron(): Promise<string | null> {
|
|
872
1001
|
const tokenErr = await this.ensureToken();
|
|
873
1002
|
if (tokenErr) {
|
|
@@ -884,16 +1013,31 @@ export class SoundCloudWatcher {
|
|
|
884
1013
|
]);
|
|
885
1014
|
|
|
886
1015
|
const lines: string[] = [];
|
|
887
|
-
|
|
1016
|
+
|
|
1017
|
+
// Format follower notifications
|
|
1018
|
+
const hasFollowerUpdates = accountNotifs.followers.length > 0;
|
|
1019
|
+
const hasEngagement = accountNotifs.engagement.length > 0;
|
|
1020
|
+
|
|
1021
|
+
if (hasFollowerUpdates || hasEngagement) {
|
|
888
1022
|
lines.push("**Account:**");
|
|
889
|
-
|
|
1023
|
+
for (const fn of accountNotifs.followers) {
|
|
1024
|
+
lines.push(...this.formatFollowerNotification(fn));
|
|
1025
|
+
}
|
|
1026
|
+
for (const e of accountNotifs.engagement) {
|
|
1027
|
+
lines.push(`- ${e}`);
|
|
1028
|
+
}
|
|
890
1029
|
lines.push("");
|
|
891
1030
|
}
|
|
1031
|
+
|
|
892
1032
|
if (releases.length) {
|
|
893
1033
|
lines.push("**New Releases:**");
|
|
894
1034
|
for (const r of releases) {
|
|
895
|
-
|
|
896
|
-
|
|
1035
|
+
if (this.includeLinks && r.url) {
|
|
1036
|
+
lines.push(`- **${r.artist}** dropped: ${r.title}`);
|
|
1037
|
+
lines.push(` ${r.url}`);
|
|
1038
|
+
} else {
|
|
1039
|
+
lines.push(`- **${r.artist}** dropped: ${r.title}`);
|
|
1040
|
+
}
|
|
897
1041
|
}
|
|
898
1042
|
lines.push("");
|
|
899
1043
|
}
|