ccdeck 3.27.0 → 3.27.4
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/dist/web/assets/{BrowserWatchModal-Ce0hVjif.js → BrowserWatchModal-BHLpHBe4.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-Cf6oqnB8.js → UsageHistoryModal-U6BQJFcE.js} +1 -1
- package/dist/web/assets/index-B5yzqlDA.css +1 -0
- package/dist/web/assets/index-BSsa18Kr.js +297 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/src/server/account-projects.mjs +14 -2
- package/src/server/best-of-nostalgia.mjs +63 -0
- package/src/server/cafe-music-bgm.mjs +63 -0
- package/src/server/good-life-radio.mjs +63 -0
- package/src/server/index.mjs +60 -0
- package/src/server/live-radio-mix.mjs +56 -0
- package/src/server/lofi-girl.mjs +76 -0
- package/dist/web/assets/index-B5RadDLY.css +0 -1
- package/dist/web/assets/index-BrjE0Aat.js +0 -297
package/dist/web/index.html
CHANGED
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
45
45
|
})();
|
|
46
46
|
</script>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
47
|
+
<script type="module" crossorigin src="/assets/index-BSsa18Kr.js"></script>
|
|
48
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B5yzqlDA.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
|
51
51
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.27.
|
|
3
|
+
"version": "3.27.4",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -56,6 +56,30 @@
|
|
|
56
56
|
"body": "Each account card's ⋯ menu now breaks its cost down by project, over 7d / 30d / all."
|
|
57
57
|
}
|
|
58
58
|
],
|
|
59
|
+
"3.27.1": [
|
|
60
|
+
{
|
|
61
|
+
"title": "🎨 Make Appearance easier to tune",
|
|
62
|
+
"body": "The Appearance dialog now has clearer sections, stronger selection states, and a calmer layout for choosing a theme and configuring music. Your existing settings and keyboard shortcuts continue to work."
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"3.27.2": [
|
|
66
|
+
{
|
|
67
|
+
"title": "💰 Project costs now match Usage",
|
|
68
|
+
"body": "Project cost totals no longer count the same Claude request once for every content block. Existing project history is rebuilt automatically so the totals align with the Usage panel."
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"3.27.3": [
|
|
72
|
+
{
|
|
73
|
+
"title": "🎵 Station selection works in the desktop app",
|
|
74
|
+
"body": "The music station picker now opens inside ccdeck instead of relying on the operating system popup, so choosing a station works reliably in the Electron app."
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"3.27.4": [
|
|
78
|
+
{
|
|
79
|
+
"title": "✅ Desktop picker release checks are green",
|
|
80
|
+
"body": "The station picker now keeps the same keyboard, motion, focus, and layout behavior as the rest of ccdeck across desktop and browser builds."
|
|
81
|
+
}
|
|
82
|
+
],
|
|
59
83
|
"3.25.0": [
|
|
60
84
|
{
|
|
61
85
|
"title": "🖱️ A click goes to the session, a double-click opens the details",
|
|
@@ -34,6 +34,7 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
|
|
|
34
34
|
* a per-account total is never quietly inflated by work that is not that
|
|
35
35
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
36
36
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
37
|
+
const STATE_VERSION = 2;
|
|
37
38
|
|
|
38
39
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
39
40
|
export function statePath(home = homedir()) {
|
|
@@ -139,6 +140,10 @@ export function foldLine(tally, line, timeline, fallbackCwd) {
|
|
|
139
140
|
if (!line || !line.includes('"usage"')) return;
|
|
140
141
|
let obj = null;
|
|
141
142
|
try { obj = JSON.parse(line); } catch { return; }
|
|
143
|
+
// Claude writes one assistant record for each content block in a request.
|
|
144
|
+
// Every block repeats the request's usage, so only block 0 is billable for
|
|
145
|
+
// this rollup. Older records without the field are kept compatible.
|
|
146
|
+
if (obj?.apiBlockIndex !== undefined && obj.apiBlockIndex !== 0) return;
|
|
142
147
|
const usage = obj?.message?.usage;
|
|
143
148
|
if (!usage || typeof usage !== "object") return;
|
|
144
149
|
const ts = Date.parse(obj?.timestamp);
|
|
@@ -311,7 +316,7 @@ export function createProjectRollup({
|
|
|
311
316
|
setInterval: setIv = setInterval,
|
|
312
317
|
clearInterval: clearIv = clearInterval,
|
|
313
318
|
} = {}) {
|
|
314
|
-
let state = { version:
|
|
319
|
+
let state = { version: STATE_VERSION, cursors: {}, tally: {} };
|
|
315
320
|
let timer = null;
|
|
316
321
|
let running = false;
|
|
317
322
|
let dirty = false;
|
|
@@ -322,7 +327,14 @@ export function createProjectRollup({
|
|
|
322
327
|
loaded = true;
|
|
323
328
|
try {
|
|
324
329
|
const disk = JSON.parse(await readFile(stateFile, "utf8"));
|
|
325
|
-
if (disk && disk.version ===
|
|
330
|
+
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
331
|
+
state = disk;
|
|
332
|
+
} else if (disk && disk.version === 1) {
|
|
333
|
+
// Version 1 counted every API content block. Keep the heartbeat so a
|
|
334
|
+
// restart gap remains fenced, but rebuild the tally from transcripts.
|
|
335
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, lastAlive: disk.lastAlive };
|
|
336
|
+
dirty = true;
|
|
337
|
+
}
|
|
326
338
|
} catch { /* first run: empty state */ }
|
|
327
339
|
}
|
|
328
340
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const NOSTALGIA_URL = "https://www.youtube.com/@bestofnostalgia";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchBestOfNostalgia({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(NOSTALGIA_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "best-of-nostalgia", label: "Best of Nostalgia — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetBestOfNostalgia() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const CAFE_MUSIC_URL = "https://www.youtube.com/channel/UCJhjE7wbdYAae1G25m0tHAA";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchCafeMusicBgm({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(CAFE_MUSIC_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "cafe-music-bgm", label: "Cafe Music BGM — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetCafeMusicBgm() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const GOOD_LIFE_URL = "https://www.youtube.com/@TheGoodLiferadio";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchGoodLifeRadio({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(GOOD_LIFE_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "good-life-radio", label: "The Good Life Radio — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetGoodLifeRadio() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -309,6 +309,39 @@ export function payloadChars(raw) {
|
|
|
309
309
|
return n;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
async function handleLiveRadioMix(req, res) {
|
|
313
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
|
|
314
|
+
return send(res, 200, { ok: true, live: false, off: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { fetchLiveRadioMix } = await import(
|
|
318
|
+
pathToFileURL(join(PKG_ROOT, "src/server/live-radio-mix.mjs")).href
|
|
319
|
+
);
|
|
320
|
+
const answer = await fetchLiveRadioMix();
|
|
321
|
+
send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Radio Mix is not live" });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function handleBestOfNostalgia(req, res) {
|
|
325
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
|
|
326
|
+
const { fetchBestOfNostalgia } = await import(pathToFileURL(join(PKG_ROOT, "src/server/best-of-nostalgia.mjs")).href);
|
|
327
|
+
const answer = await fetchBestOfNostalgia();
|
|
328
|
+
send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Best of Nostalgia is not live" });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleGoodLifeRadio(req, res) {
|
|
332
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
|
|
333
|
+
const { fetchGoodLifeRadio } = await import(pathToFileURL(join(PKG_ROOT, "src/server/good-life-radio.mjs")).href);
|
|
334
|
+
const answer = await fetchGoodLifeRadio();
|
|
335
|
+
send(res, answer ? 200 : 404, answer ?? { ok: false, error: "The Good Life Radio is not live" });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function handleCafeMusicBgm(req, res) {
|
|
339
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") return send(res, 200, { ok: true, live: false, off: true });
|
|
340
|
+
const { fetchCafeMusicBgm } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cafe-music-bgm.mjs")).href);
|
|
341
|
+
const answer = await fetchCafeMusicBgm();
|
|
342
|
+
send(res, answer ? 200 : 404, answer ?? { ok: false, error: "Cafe Music BGM is not live" });
|
|
343
|
+
}
|
|
344
|
+
|
|
312
345
|
// Where the charge rides. A Symbol key rather than an ordinary field, because
|
|
313
346
|
// the envelope is JSON.stringify'd on the hot path into both the SSE frame and
|
|
314
347
|
// the events.jsonl line, and JSON.stringify ignores symbol-keyed properties
|
|
@@ -5828,6 +5861,23 @@ async function handleClaudeFm(req, res) {
|
|
|
5828
5861
|
send(res, 200, answer);
|
|
5829
5862
|
}
|
|
5830
5863
|
|
|
5864
|
+
async function handleLofiGirl(req, res) {
|
|
5865
|
+
if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
|
|
5866
|
+
return send(res, 200, { ok: true, live: false, off: true });
|
|
5867
|
+
}
|
|
5868
|
+
const url = new URL(req.url, "http://localhost");
|
|
5869
|
+
const station = url.searchParams.get("station");
|
|
5870
|
+
if (!["relax", "game", "vibe", "sleep"].includes(station)) {
|
|
5871
|
+
return send(res, 400, { ok: false, error: "unknown Lofi Girl station" });
|
|
5872
|
+
}
|
|
5873
|
+
const { fetchLofiStations } = await import(
|
|
5874
|
+
pathToFileURL(join(PKG_ROOT, "src/server/lofi-girl.mjs")).href
|
|
5875
|
+
);
|
|
5876
|
+
const stations = await fetchLofiStations();
|
|
5877
|
+
const answer = stations[station];
|
|
5878
|
+
send(res, answer ? 200 : 404, answer ?? { ok: false, error: "station is not live" });
|
|
5879
|
+
}
|
|
5880
|
+
|
|
5831
5881
|
async function handleCodexQuota(req, res) {
|
|
5832
5882
|
const { fetchCodexQuota } = await import(
|
|
5833
5883
|
pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
|
|
@@ -6215,6 +6265,11 @@ const PINNED_MODULES = [
|
|
|
6215
6265
|
"browser-watch.mjs",
|
|
6216
6266
|
"browser-watch-store.mjs",
|
|
6217
6267
|
"claude-fm.mjs",
|
|
6268
|
+
"lofi-girl.mjs",
|
|
6269
|
+
"live-radio-mix.mjs",
|
|
6270
|
+
"best-of-nostalgia.mjs",
|
|
6271
|
+
"good-life-radio.mjs",
|
|
6272
|
+
"cafe-music-bgm.mjs",
|
|
6218
6273
|
// system-metrics.mjs's two, on the platforms that have them, and the one
|
|
6219
6274
|
// installer.mjs reaches for while it rewrites the hooks.
|
|
6220
6275
|
"macmon.mjs",
|
|
@@ -7539,6 +7594,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7539
7594
|
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|
|
7540
7595
|
if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
|
|
7541
7596
|
if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
|
|
7597
|
+
if (req.method === "GET" && url.pathname === "/api/lofi-girl") return guard(handleLofiGirl(req, res), res);
|
|
7598
|
+
if (req.method === "GET" && url.pathname === "/api/live-radio-mix") return guard(handleLiveRadioMix(req, res), res);
|
|
7599
|
+
if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);
|
|
7600
|
+
if (req.method === "GET" && url.pathname === "/api/good-life-radio") return guard(handleGoodLifeRadio(req, res), res);
|
|
7601
|
+
if (req.method === "GET" && url.pathname === "/api/cafe-music-bgm") return guard(handleCafeMusicBgm(req, res), res);
|
|
7542
7602
|
|
|
7543
7603
|
// Through writeJsonArray rather than `send`, and through `guard` like every
|
|
7544
7604
|
// route above it: this is the one answer whose size is the ring's size, and
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const RADIO_MIX_URL = "https://www.youtube.com/@liveradiomix";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const live = html.indexOf('"liveBadgeText":"LIVE"');
|
|
15
|
+
if (live < 0) return null;
|
|
16
|
+
const window = html.slice(Math.max(0, live - 2_000), live + 2_000);
|
|
17
|
+
return window.match(/"videoId":"([\w-]{11})"/)?.[1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function fetchLiveRadioMix({ fetchImpl } = {}) {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
23
|
+
if (inflight) return inflight;
|
|
24
|
+
const get = fetchImpl ?? fetch;
|
|
25
|
+
inflight = (async () => {
|
|
26
|
+
try {
|
|
27
|
+
const response = await get(RADIO_MIX_URL, {
|
|
28
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
29
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
30
|
+
redirect: "follow",
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) return settle(null);
|
|
33
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
34
|
+
return settle(video ? { id: "radio-mix", label: "Radio Mix — Live", video } : null);
|
|
35
|
+
} catch {
|
|
36
|
+
return settle(null);
|
|
37
|
+
} finally {
|
|
38
|
+
inflight = null;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return inflight;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function settle(value) {
|
|
45
|
+
cache = value;
|
|
46
|
+
cacheAt = Date.now();
|
|
47
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function forgetLiveRadioMix() {
|
|
52
|
+
cache = null;
|
|
53
|
+
cacheAt = 0;
|
|
54
|
+
cacheTtl = 0;
|
|
55
|
+
inflight = null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const LOFI_GIRL_URL = "https://www.youtube.com/@LofiGirl";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
export const LOFI_STATIONS = {
|
|
9
|
+
relax: { label: "Lofi Girl — relax/study" , terms: ["relax/study"] },
|
|
10
|
+
game: { label: "Lofi Girl — chill/game", terms: ["chill/game"] },
|
|
11
|
+
vibe: { label: "Lofi Girl — vibe/chill", terms: ["vibe/chill"] },
|
|
12
|
+
sleep: { label: "Lofi Girl — sleep/chill", terms: ["sleep/chill"] },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let cache = null;
|
|
16
|
+
let cacheAt = 0;
|
|
17
|
+
let cacheTtl = 0;
|
|
18
|
+
let inflight = null;
|
|
19
|
+
|
|
20
|
+
function stationVideo(html, terms) {
|
|
21
|
+
for (const term of terms) {
|
|
22
|
+
const index = html.toLowerCase().indexOf(term);
|
|
23
|
+
if (index < 0) continue;
|
|
24
|
+
const start = Math.max(0, index - 4_000);
|
|
25
|
+
const window = html.slice(start, index + 4_000);
|
|
26
|
+
const ids = [...window.matchAll(/"videoId":"([\w-]{11})"/g)];
|
|
27
|
+
if (ids.length) {
|
|
28
|
+
ids.sort((a, b) => Math.abs(start + a.index - index) - Math.abs(start + b.index - index));
|
|
29
|
+
return ids[0][1];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function fetchLofiStations({ fetchImpl } = {}) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
38
|
+
if (inflight) return inflight;
|
|
39
|
+
const get = fetchImpl ?? fetch;
|
|
40
|
+
inflight = (async () => {
|
|
41
|
+
try {
|
|
42
|
+
const response = await get(LOFI_GIRL_URL, {
|
|
43
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
44
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
45
|
+
redirect: "follow",
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) return settle({});
|
|
48
|
+
const html = (await response.text()).slice(0, READ_LIMIT);
|
|
49
|
+
const stations = Object.fromEntries(
|
|
50
|
+
Object.entries(LOFI_STATIONS)
|
|
51
|
+
.map(([id, station]) => [id, { id, label: station.label, video: stationVideo(html, station.terms) }])
|
|
52
|
+
.filter(([, station]) => station.video),
|
|
53
|
+
);
|
|
54
|
+
return settle(stations);
|
|
55
|
+
} catch {
|
|
56
|
+
return settle({});
|
|
57
|
+
} finally {
|
|
58
|
+
inflight = null;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return inflight;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function settle(value) {
|
|
65
|
+
cache = value;
|
|
66
|
+
cacheAt = Date.now();
|
|
67
|
+
cacheTtl = Object.keys(value).length ? CACHE_MS : MISS_CACHE_MS;
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function forgetLofiStations() {
|
|
72
|
+
cache = null;
|
|
73
|
+
cacheAt = 0;
|
|
74
|
+
cacheTtl = 0;
|
|
75
|
+
inflight = null;
|
|
76
|
+
}
|