The source
1394 lines · 62 KB · MIT license · this exact file is what you installEverything TuneStatus does is in this one file —
the setup wizard, the dashboard, and the 30-second sync loop. Search it for
fetch( and you'll find every network call it can ever make: spotify.com
and slack.com, nothing else.
Download it and it's yours.
// TuneStatus — your music, in your Slack status. https://tunestatus.com
//
// MIT License · Copyright (c) 2026 Brent (tunestatus.com)
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software, to deal in the Software without restriction. THE SOFTWARE
// IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. Full text:
// https://opensource.org/license/mit
//
// TuneStatus (local edition) — Spotify → Slack status sync, 100% on this machine.
//
// You bring your own Slack app (paste its token) and your own Spotify app
// (created via the guided wizard) — everything is scoped to your own status
// and currently-playing track, and stored only in ~/.tunestatus-local.json.
//
// Web UI (http://127.0.0.1:8898):
// GET / — setup wizard, live status, settings
// POST /slack-token — save & verify your Slack token
// POST /spotify-credentials, /reset-spotify — your Spotify app credentials
// GET /auth/spotify, /callback/spotify — Spotify authorization
// POST /settings, /autostart, /disconnect
// Sync: every 30 seconds while this app is running.
const SPOTIFY_SCOPE = "user-read-currently-playing";
const STATUS_EXPIRATION_S = 300; // refreshed before expiry; clears within 5 min if the app stops
// The real Spotify logo PNG, served at /spotify.png and used in the UI.
const EMOJI_CHOICES = [":spotify:", ":musical_note:", ":headphones:", ":notes:", ":guitar:", ":cd:", ":radio:"];
const DEFAULT_EMOJI = ":musical_note:"; // most workspaces lack a custom :spotify: emoji
// ---------- helpers ----------
function pngResponse(base64, contentType) {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new Response(bytes, {
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=86400" },
});
}
function baseUrl(request) {
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
}
function getUserId(request) {
const cookie = request.headers.get("Cookie") || "";
const match = cookie.match(/(?:^|;\s*)uid=([a-f0-9-]+)/);
return match ? match[1] : null;
}
function withUserCookie(response, userId) {
response.headers.append(
"Set-Cookie",
`uid=${userId}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax`
);
return response;
}
async function getUser(env, userId) {
if (!userId) return null;
return env.USERS.get(`user:${userId}`, "json");
}
async function putUser(env, userId, user) {
await env.USERS.put(`user:${userId}`, JSON.stringify(user));
}
function redirect(location, status = 302) {
return new Response(null, { status, headers: { Location: location } });
}
// CSRF protection for the OAuth flows: a random state in a short-lived cookie,
// echoed back by the provider and compared in the callback.
function redirectWithState(location, state) {
const res = redirect(location);
res.headers.append(
"Set-Cookie",
`oauth_state=${state}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax`
);
return res;
}
function stateMatches(request) {
const url = new URL(request.url);
const returned = url.searchParams.get("state");
const cookie = (request.headers.get("Cookie") || "").match(/(?:^|;\s*)oauth_state=([a-f0-9]+)/);
return Boolean(returned && cookie && returned === cookie[1]);
}
// The user's own Spotify app credentials.
function spotifyCreds(env, user) {
return {
id: user?.spotifyClientId || env.SPOTIFY_CLIENT_ID,
secret: user?.spotifyClientSecret || env.SPOTIFY_CLIENT_SECRET,
};
}
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
// Current hour (0-23) in an IANA timezone; null if the tz string is invalid.
function hourIn(tz) {
try {
return parseInt(
new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: tz })
.format(new Date()),
10
);
} catch {
return null;
}
}
// Is this user's sync currently active per their work-hours window?
function withinWorkHours(user) {
const wh = user.workHours;
if (!wh) return true;
const hour = hourIn(wh.tz);
if (hour === null) return true; // bad tz — fail open rather than go silent
// Window may wrap midnight (e.g. 22 → 6).
return wh.start <= wh.end
? hour >= wh.start && hour < wh.end
: hour >= wh.start || hour < wh.end;
}
// ---------- user index ----------
async function getUserIndex(env) {
const idx = await env.USERS.get("index:users", "json");
if (Array.isArray(idx)) return idx;
// Self-heal: build once via list() if quota allows, else start empty and let
// ensureInIndex repopulate as users touch the site.
try {
const list = await env.USERS.list({ prefix: "user:" });
const built = list.keys.map((k) => k.name);
await env.USERS.put("index:users", JSON.stringify(built));
return built;
} catch {
return [];
}
}
async function ensureInIndex(env, userId) {
const key = `user:${userId}`;
const idx = await getUserIndex(env);
if (!idx.includes(key)) {
idx.push(key);
await env.USERS.put("index:users", JSON.stringify(idx));
}
}
async function removeFromIndex(env, userId) {
const key = `user:${userId}`;
const idx = await getUserIndex(env);
if (idx.includes(key)) {
await env.USERS.put("index:users", JSON.stringify(idx.filter((k) => k !== key)));
}
}
// ---------- landing page ----------
async function countConnected(env) {
const keys = await getUserIndex(env);
const users = await Promise.all(keys.map((name) => env.USERS.get(name, "json")));
return users.filter((u) => u?.slackUserToken && u?.spotifyRefreshToken).length;
}
async function userCount(env) {
return new Response(String(await countConnected(env)), {
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
});
}
async function home(request, env) {
const saved = new URL(request.url).searchParams.get("saved") === "1";
const userId = getUserId(request);
const user = await getUser(env, userId);
const slackDone = Boolean(user?.slackUserToken);
const credsDone = Boolean(user?.spotifyClientId);
const spotifyDone = Boolean(user?.spotifyRefreshToken);
const allDone = slackDone && spotifyDone;
const callbackUrl = `${baseUrl(request)}/callback/spotify`;
const nUsers = await countConnected(env).catch(() => null);
const spotifyEmoji = allDone ? await checkSpotifyEmoji(user.slackUserToken) : null;
const stepState = (done, locked) => (done ? "done" : locked ? "locked" : "");
const body = `
<header class="site">
<a class="brand" href="/" aria-label="TuneStatus">▶ Tune<span class="accentt">Status</span><span class="cursor accentt">_</span></a>
<nav>
<a href="#setup">Setup</a>
<a href="#privacy">Privacy</a>
<div class="themeseg" role="group" aria-label="Theme">
<button type="button" data-theme="dark">Dark</button><button type="button" data-theme="light">Light</button>
</div>
</nav>
</header>
<main>
<section class="hero">
<div class="hero-left">
<div class="eyebrow"><span class="dot"></span> LIVE SYNC</div>
<h1>Your music,<br>in your Slack status.</h1>
<p class="lede">While Spotify plays, your Slack status shows the track. When it
stops, it clears. Runs entirely on this computer — one short setup (about 8 minutes), then forget it exists.</p>
<div class="cta-row">
${allDone
? `<a class="btn-primary" href="#sync">You're live — view status</a>`
: `<a class="btn-primary" href="#setup">Set up once (~8 min)</a>`}
<a class="btn-secondary" href="#privacy">How it works</a>
</div>
<div class="trust">◇ Free forever · ◇ Your keys stay yours · ◇ No account needed${nUsers >= 3 ? ` · ◇ Loved by <span id="usercount">${nUsers}</span> <span id="userword">users</span>` : ""}</div>
</div>
<div class="hero-right">
<div class="statuscard">
<div class="statuscard-label">YOUR SLACK STATUS, LIVE</div>
<div class="statuscard-body" id="livecard">
${allDone ? statusPanel(user) : `
<div class="mockstatus">
<div class="mockavatar"></div>
<div>
<div class="mockname">you</div>
<div class="mocktrack">♪ Mirror Talk — Nic D</div>
</div>
</div>
<div class="mocknote">— what your teammates will see</div>`}
</div>
</div>
</div>
</section>
${allDone ? syncSection(user, saved, spotifyEmoji) : ""}
<section id="privacy" class="privacystrip">
<span class="privlabel">PRIVACY</span>
<span class="privtext">Everything runs on this computer. You create your own Slack and Spotify
apps, and your keys are saved to a file in your home folder — they never leave this
machine. Disconnect deletes them.</span>
</section>
<section id="setup" class="setup">
<div class="setup-head"><h2>Setup</h2><span class="setup-time">~8 min · one-time</span></div>
<div class="setup-grid">
<div class="card ${stepState(slackDone, false)}">
<div class="step-eyebrow">STEP 01</div>
<h3>Create your free Slack app</h3>
${slackDone ? "" : `<p class="cardnote">Don't worry — it's just a permission slip
that lets TuneStatus update your status. Five clicks on Slack's website,
nothing to build.</p>`}
${slackDone
? `<p class="okline">✓ Connected${user.slackName ? " as <b>" + esc(user.slackName) + "</b>" : ""}</p>`
: `
<ol>
<li>Open <a href="https://api.slack.com/apps" target="_blank" rel="noopener">api.slack.com/apps</a>
→ <b>Create New App</b> → <b>From scratch</b>.</li>
<li>Name it <code>TuneStatus</code>, pick your workspace, <b>Create App</b>.</li>
<li>In the left sidebar, open <b>OAuth & Permissions</b>. Scroll down to
<b>User Token Scopes</b> (the <u>lower</u> scopes box — not Bot Token Scopes)
and click <b>Add an OAuth Scope</b>. Add these three, one at a time:
<code>users.profile:write</code>, <code>users.profile:read</code>,
<code>emoji:read</code>.</li>
<li>Scroll back to the top of that page and click <b>Install to Workspace</b>
(it may show your workspace's name instead) → <b>Allow</b>.</li>
<li>You land back on the same page — copy the <b>User OAuth Token</b>
(it starts with <code>xoxp-</code>; if yours starts with <code>xoxb-</code>,
that's the wrong one) and paste it below.</li>
</ol>
<form method="POST" action="/slack-token">
<input name="slack_token" type="password" required pattern="xoxp-.+"
placeholder="xoxp-…" autocomplete="off" aria-label="Slack user token">
<button class="btn-card" type="submit">Save & verify</button>
</form>`}
</div>
<div class="card ${stepState(credsDone || spotifyDone, !slackDone)}">
<div class="step-eyebrow">STEP 02</div>
<h3>Create your free Spotify app</h3>
${credsDone || spotifyDone ? "" : `<p class="cardnote">Also just a permission slip —
a two-minute form on Spotify's website. <b>Made one before?</b> Skip the form:
open your existing app on the dashboard and copy its Client ID and secret
straight into the boxes below.</p>`}
${credsDone || spotifyDone
? `<p class="okline">✓ ${credsDone ? "Credentials saved" : "Using original shared app"}</p>
${!spotifyDone ? `<form method="POST" action="/reset-spotify">
<button class="linkish" type="submit">Typo? Re-enter credentials</button></form>` : ""}`
: `
<ol>
<li>Open <a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener">developer.spotify.com/dashboard</a>
and log in with <b>the Spotify account that plays your music</b>.
(First time here? Spotify shows its Developer Terms — accept and continue.)
Then click <b>Create app</b>.</li>
<li>Name/description: anything. <b>Redirect URI</b> — paste exactly, click <b>Add</b>:
<span class="chip"><code id="cb">${callbackUrl}</code>
<button type="button" class="copy" onclick="copyCb(this)">Copy</button></span></li>
<li>Tick <b>Web API</b>, accept terms, <b>Save</b>.</li>
<li>Copy the <b>Client ID</b>; click <b>View client secret</b>, copy that too.</li>
</ol>
<form method="POST" action="/spotify-credentials">
<input name="client_id" required placeholder="Client ID"
autocomplete="off" aria-label="Spotify client ID">
<input name="client_secret" type="password" required
placeholder="Client secret" autocomplete="off" aria-label="Spotify client secret">
<button class="btn-card" type="submit">Save & continue</button>
</form>`}
</div>
<div class="card ${stepState(spotifyDone, !credsDone && !spotifyDone)}">
<div class="step-eyebrow">STEP 03</div>
<h3>Connect Spotify</h3>
${spotifyDone
? `<p class="okline">✓ Connected — you're done</p>`
: `<p class="cardnote">One authorization click, and you're done — your status
updates itself from now on.</p>
${credsDone
? `<a class="btn-primary" href="/auth/spotify">Connect Spotify →</a>`
: `<p class="finenote">Complete steps 1 & 2 first.</p>
<span class="btn-primary disabled">Connect Spotify →</span>`}`}
</div>
</div>
</section>
<section class="faq">
<h2>FAQ</h2>
<details><summary>Is this safe? What can it see?</summary>
<p>It can see the song you're playing and your own Slack status — nothing else.
No messages, no files, nothing about anyone but you. And because you create the
connections in your own accounts, you can shut it all off yourself, anytime.</p></details>
<details><summary>Where do my keys go?</summary>
<p>Into <code>~/.tunestatus-local.json</code> on this computer — nowhere else.
No server, no cloud, no third party. Disconnect deletes them. There's no
listening history either; each song overwrites the last.</p></details>
<details><summary>What if I set my own status, like "In a meeting"?</summary>
<p>Your status wins. TuneStatus never overwrites something you set by hand — it
waits, and your music comes back after you clear it.</p></details>
<details><summary>Can I hide my music sometimes?</summary>
<p>Yep — there's a pause switch in your sync settings, plus an optional "only
during work hours" schedule.</p></details>
<details><summary>How fast does it update?</summary>
<p>Within ~30 seconds. If this app stops running (or your laptop sleeps), your
status clears itself automatically a few minutes later — walking away from your
desk politely clears your status. (Podcasts are ignored, by the way.)</p></details>
<details><summary>What does it cost?</summary>
<p>Nothing. If it made you smile, there's a Dr Pepper button below. 🥤</p></details>
</section>
</main>
<footer class="site">
<span>Free forever · made by Brent · if it made your day 1% more musical…</span>
<a class="donate" href="https://www.buymeacoffee.com/brentdev" target="_blank" rel="noopener">Buy me a Dr Pepper 🥤</a>
</footer>
<script>
function copyCb(btn) {
var el = document.getElementById("cb");
if (el) navigator.clipboard.writeText(el.textContent)
.then(() => { btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500); });
}
var tzInput = document.getElementById("tz");
if (tzInput) tzInput.value = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
// "Settings saved" toast: clean the URL and auto-fade.
(function () {
var toast = document.querySelector(".toast");
if (!toast) return;
history.replaceState(null, "", location.pathname + location.hash);
setTimeout(function () {
toast.style.transition = "opacity .6s"; toast.style.opacity = "0";
setTimeout(function () { toast.remove(); }, 700);
}, 3500);
})();
// Warn before leaving with unsaved form changes.
(function () {
var dirty = false;
document.querySelectorAll("form").forEach(function (f) {
f.addEventListener("input", function () { dirty = true; });
f.addEventListener("change", function () { dirty = true; });
f.addEventListener("submit", function () { dirty = false; });
});
window.addEventListener("beforeunload", function (e) {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
});
})();
// Live user counter.
(function () {
var c = document.getElementById("usercount"), w = document.getElementById("userword");
if (!c) return;
setInterval(function () {
fetch("/count").then(function (r) { return r.ok ? r.text() : null; }).then(function (n) {
if (n === null || n === c.textContent) return;
c.textContent = n;
if (w) w.textContent = n === "1" ? "user" : "users";
}).catch(function () {});
}, 10000);
})();
// Wizard mode: in the active setup card, show one instruction at a time.
(function () {
var card = Array.prototype.find.call(
document.querySelectorAll(".setup-grid .card"),
function (c) { return !c.classList.contains("done") && !c.classList.contains("locked") && c.querySelector("ol"); }
);
if (!card) return;
var items = Array.prototype.slice.call(card.querySelectorAll("ol > li"));
var form = card.querySelector("form");
if (items.length < 2) return;
var i = 0;
var nav = document.createElement("div");
nav.className = "wiznav";
nav.innerHTML = '<button type="button" class="btn-card" id="wizback">← Back</button>' +
'<span class="wizprog"></span>' +
'<button type="button" class="btn-card wiznext" id="wiznext">Done, next →</button>';
card.querySelector("ol").after(nav);
function render() {
items.forEach(function (li, n) { li.style.display = n === i ? "" : "none"; });
var atForm = i >= items.length;
if (atForm) items.forEach(function (li) { li.style.display = "none"; });
nav.querySelector(".wizprog").textContent = atForm
? "last thing:" : (i + 1) + " of " + items.length;
nav.querySelector("#wizback").style.visibility = i === 0 ? "hidden" : "visible";
nav.querySelector("#wiznext").style.display = atForm ? "none" : "";
if (form) form.style.display = atForm ? "" : "none";
}
nav.querySelector("#wizback").addEventListener("click", function () { if (i > 0) { i--; render(); } });
nav.querySelector("#wiznext").addEventListener("click", function () { i++; render(); });
render();
})();
// Keep the live status card fresh.
(function () {
var el = document.querySelector("#livecard section.status");
if (!el) return;
setInterval(function () {
fetch("/panel").then(function (r) { return r.ok && r.status !== 204 ? r.text() : null; })
.then(function (html) {
if (!html) return;
var live = document.querySelector("#livecard section.status");
if (live) { live.outerHTML = html; }
}).catch(function () {});
}, 30000);
})();
</script>`;
return withUserCookie(
new Response(page(body), { headers: { "Content-Type": "text/html; charset=utf-8" } }),
userId || crypto.randomUUID()
);
}
// Post-setup control center: live status + settings.
// Does this workspace have a custom :spotify: emoji? Needs the emoji:read scope.
// Returns { available, url } or { scopeMissing: true } or null on any failure.
async function checkSpotifyEmoji(token) {
try {
const res = await slackApi(token, "emoji.list", {});
if (!res.ok) return res.error === "missing_scope" ? { scopeMissing: true } : null;
let url = res.emoji && res.emoji["spotify"];
if (typeof url === "string" && url.startsWith("alias:")) url = res.emoji[url.slice(6)];
return url ? { available: true, url } : { available: false };
} catch {
return null;
}
}
function syncSection(user, saved, spotifyEmoji) {
const wh = user.workHours;
const hourOptions = (sel) =>
Array.from({ length: 24 }, (_, h) =>
`<option value="${h}" ${h === sel ? "selected" : ""}>${String(h).padStart(2, "0")}:00</option>`).join("");
const emoji = user.emoji || DEFAULT_EMOJI;
const labels = { ":musical_note:": "🎵", ":headphones:": "🎧", ":notes:": "🎶", ":guitar:": "🎸", ":cd:": "💿", ":radio:": "📻" };
if (spotifyEmoji?.available) {
labels[":spotify:"] = `<img src="${spotifyEmoji.url}" alt="Spotify" width="20" height="20" style="display:block">`;
}
const emojiRadios = EMOJI_CHOICES.filter((e) => labels[e]).map((e) =>
`<label class="emoji"><input type="radio" name="emoji" value="${e}" ${e === emoji ? "checked" : ""}>
${labels[e]}</label>`).join("");
const emojiTip = spotifyEmoji?.available ? "" : spotifyEmoji?.scopeMissing ? `
<p class="finenote">Want the Spotify logo as an option here? Add the <code>emoji:read</code>
scope to your Slack app (User Token Scopes), reinstall, and paste the new token.</p>` : `
<p class="finenote">Want the Spotify logo here? Your workspace doesn't have a
<code>:spotify:</code> emoji yet — takes 2 minutes to add:</p>
<ol class="finenote-list">
<li>Download it from <a href="https://slackmojis.com/emojis/1358-spotify" target="_blank" rel="noopener">slackmojis.com</a></li>
<li>In Slack: your workspace name → <b>Customize workspace</b> → <b>Emoji</b> → <b>Add Custom Emoji</b></li>
<li>Upload the image and name it exactly <code>spotify</code></li>
<li>Refresh this page — the logo appears here automatically</li>
</ol>`;
return `
<section id="sync" class="setup">
<div class="setup-head"><h2>Your sync</h2><span class="setup-time">checked every 30 seconds</span></div>
${saved ? `<p class="toast">✓ Settings saved</p>` : ""}
<div class="card">
<div class="step-eyebrow">SETTINGS</div>
<form method="POST" action="/settings" class="settings-form">
<input type="hidden" name="tz" id="tz" value="${esc(wh?.tz || "")}">
<label class="inline"><input type="checkbox" name="paused" ${user.paused ? "checked" : ""}>
Pause sync (hide my music for now)</label>
<div class="field"><span class="fieldname">Status emoji</span><div class="emojirow">${emojiRadios}</div>${emojiTip}</div>
<label class="inline"><input type="checkbox" name="use_hours" ${wh ? "checked" : ""}>
Only sync between
<select name="start">${hourOptions(wh?.start ?? 9)}</select> and
<select name="end">${hourOptions(wh?.end ?? 18)}</select> (your local time)</label>
<div><button class="btn-card" type="submit">Save settings</button></div>
</form>
${typeof process !== "undefined" && process.platform === "darwin" ? `
<form method="POST" action="/autostart">
${isAutostartInstalled()
? `<p class="okline">✓ Starts automatically at login</p>
<input type="hidden" name="action" value="uninstall">
<button class="linkish" type="submit">Turn off</button>`
: `<input type="hidden" name="action" value="install">
<button class="btn-card" type="submit">Start automatically at login</button>
<p class="finenote">So you never have to think about TuneStatus again.</p>`}
</form>` : ""}
<form method="POST" action="/disconnect">
<button class="linkish danger-link" type="submit">Disconnect me completely</button>
</form>
</div>
</section>`;
}
// Admin-only usage overview. Requires ?key= matching the STATS_KEY secret.
async function stats(request, env) {
const key = new URL(request.url).searchParams.get("key");
if (!env.STATS_KEY || key !== env.STATS_KEY) {
return new Response("Not found", { status: 404 }); // don't advertise the route
}
const keys = await getUserIndex(env);
const users = (
await Promise.all(keys.map((name) => env.USERS.get(name, "json")))
).filter(Boolean);
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const fullyConnected = users.filter((u) => u.slackUserToken && u.spotifyRefreshToken);
const activeWeek = fullyConnected.filter((u) => u.lastPlayedAt && now - u.lastPlayedAt < 7 * day);
const playingNow = fullyConnected.filter((u) => u.lastTrack);
const ago = (ts) => {
if (!ts) return "never";
const m = Math.round((now - ts) / 60000);
if (m < 60) return `${m}m ago`;
if (m < 1440) return `${Math.round(m / 60)}h ago`;
return `${Math.round(m / 1440)}d ago`;
};
const rows = fullyConnected.map((u) => `<tr>
<td>${esc(u.slackName || "(no name)")}</td>
<td>${u.lastTrack ? "▶️ " + esc(u.lastTrack) : u.paused ? "⏸ paused" : "—"}</td>
<td>${ago(u.lastPlayedAt)}</td>
<td>${u.lastError ? "⚠️ " + esc(u.lastError) : "ok"}</td>
</tr>`).join("");
const body = `<main>
<h1>📊 Usage</h1>
<section class="status"><p>
<b>${fullyConnected.length}</b> connected user(s)
· <b>${activeWeek.length}</b> active this week
· <b>${playingNow.length}</b> playing right now
${users.length > fullyConnected.length ? `· ${users.length - fullyConnected.length} partial signup(s)` : ""}
</p></section>
<section><table style="width:100%;border-collapse:collapse" border="0">
<tr><th align="left">Who</th><th align="left">Status</th><th align="left">Last played</th><th align="left">Health</th></tr>
${rows || "<tr><td colspan=4>No connected users yet.</td></tr>"}
</table></section>
</main>`;
return new Response(page(body), { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// A fixed-size 1200x630 card for screenshotting as a Slack promo image.
function promo() {
const html = `<!doctype html>
<html><head><meta charset="utf-8"><style>
* { margin: 0; box-sizing: border-box; }
body { width: 1200px; height: 630px; background: #000; overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #00e63c;
display: flex; align-items: center; padding: 0 64px; gap: 56px; }
.left { flex: 1; }
.prompt { color: #0f5a26; font-size: 20px; margin-bottom: 22px; }
h1 { color: #00ff41; font-size: 52px; line-height: 1.15; text-shadow: 0 0 14px rgba(0,255,65,.5);
margin-bottom: 22px; }
.sub { font-size: 23px; line-height: 1.5; margin-bottom: 30px; color: #00e63c; }
.status { display: inline-flex; align-items: center; gap: 12px; background: #050d05;
border: 1px solid #0f5a26; padding: 14px 22px; font-size: 22px; color: #00ff41;
box-shadow: 0 0 24px rgba(0,255,65,.15); margin-bottom: 30px; }
.status img { width: 28px; height: 28px; }
.url { font-size: 21px; color: #000; background: #00ff41; display: inline-block;
padding: 10px 18px; font-weight: 700; }
.right { width: 420px; }
.right img { width: 100%; border: 1px solid #0f5a26;
filter: grayscale(1) sepia(1) hue-rotate(70deg) saturate(4) brightness(.85); }
.caption { text-align: center; color: #0f5a26; font-size: 17px; margin-top: 10px; }
</style></head><body>
<div class="left">
<div class="prompt">you@work:~$ ./tunestatus</div>
<h1>Your music.<br>Your Slack status.<br>Automatically.</h1>
<div class="sub">Play music on Spotify → your Slack status shows the song.
Hit pause → it clears. Set up once, forget forever.</div>
<div class="status"><img src="/spotify.png"> Mirror Talk — Nic D</div><br>
<div class="url">tunestatus.com</div>
</div>
<div class="right">
<img src="/heybro.jpg">
<div class="caption">// never get asked again</div>
</div>
</body></html>`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// Fresh HTML for the status section — polled by the page every 30s.
async function panel(request, env) {
const user = await getUser(env, getUserId(request));
if (!user?.slackUserToken || !user?.spotifyRefreshToken) {
return new Response("", { status: 204 });
}
return new Response(statusPanel(user), {
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
});
}
// "What is the sync doing right now?" — shown at the top once set up.
// "What is the sync doing right now?" — rendered into the hero status card.
function statusPanel(user) {
const rows = [];
if (user.lastError) {
rows.push(`<p class="warn">⚠ Last sync error: <code>${esc(user.lastError)}</code> —
if this persists, disconnect & reconnect below.</p>`);
}
if (user.paused) {
rows.push(`<p class="statusline"><span class="mono-tag">PAUSED</span> Your music stays private until you resume.</p>`);
} else if (user.manualStatus) {
rows.push(`<p class="statusline"><span class="mono-tag">STANDING BY</span> You set
<b>${esc(user.manualStatus)}</b> yourself — TuneStatus won't touch it. Clear it and
your music returns within a minute.</p>`);
} else if (user.lastTrack) {
rows.push(`<p class="statusline nowplaying"><span class="playdot"></span> <b>${esc(user.lastTrack)}</b></p>`);
} else {
rows.push(`<p class="statusline"><span class="mono-tag">IDLE</span> Nothing playing —
your status is clear. Put a song on and check back in a minute.</p>`);
}
return `<section class="status">${rows.join("")}</section>`;
}
function page(body) {
return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>TuneStatus (local) — your music, in your Slack status</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script>
// Apply saved theme before first paint. Dark is the default.
(function () {
if (localStorage.getItem("ts-theme") === "light") document.documentElement.className = "light";
})();
</script>
<style>
:root {
--bg: #0b0d0f; --fg: #e8eaec; --card: #111417; --inputBg: #0b0d0f; --btnBg: #1a1e22;
--line: #1a1e22; --line2: #23282d; --muted: #9aa3ab; --faint: #5f6870;
--accent: #2fd071; --onAccent: #08150c; --err: #f87171; --donate: #550212;
--shadow: 0 24px 60px rgba(0,0,0,.35);
}
html.light {
--accent: #1db956;
--bg: #f7f8f9; --fg: #171b1f; --card: #ffffff; --inputBg: #f2f4f5; --btnBg: #eceff1;
--line: #e4e8eb; --line2: #dde2e6; --muted: #5a646d; --faint: #8b959e;
--err: #dc2626; --shadow: 0 24px 60px rgba(0,0,0,.08);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font-family: "Space Grotesk", -apple-system, sans-serif;
transition: background .25s, color .25s; }
.mono, .eyebrow, .trust, .step-eyebrow, .setup-time, .statuscard-label, .privlabel,
code, .chip, .finenote, .mono-tag, input, .mocktrack, .mocknote { font-family: "IBM Plex Mono", ui-monospace, monospace; }
a { color: var(--fg); }
a:hover, .donate:hover, .btn-primary:hover, .btn-secondary:hover { opacity: .85; }
header.site, footer.site { display: flex; justify-content: space-between; align-items: center;
padding: 20px 48px; max-width: 1280px; margin: 0 auto; }
header.site { border-bottom: 1px solid var(--line); }
footer.site { border-top: 1px solid var(--line); padding: 24px 48px; color: var(--faint); font-size: 13px; }
:root { --logoFg: #e9e9ee; --logoAccent: #2fd071; }
html.light { --logoFg: #17171b; --logoAccent: #1db956; }
.brand { font-family: "IBM Plex Mono", ui-monospace, monospace; font-weight: 600; font-size: 26px;
color: var(--logoFg); text-decoration: none; }
.brand .accentt { color: var(--logoAccent); }
.cursor { animation: blink 1s step-end infinite; }
@keyframes blink { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }
@media (prefers-reduced-motion: reduce) { .cursor { animation: none; } }
nav { display: flex; align-items: center; gap: 22px; }
nav a { font-size: 14px; color: var(--muted); text-decoration: none; }
.themeseg { border: 1px solid var(--line2); border-radius: 6px; overflow: hidden; display: flex; }
.themeseg button { font-size: 12px; padding: 5px 12px; border: 0; background: transparent;
color: var(--muted); cursor: pointer; font-family: inherit; }
.themeseg button.active { background: var(--btnBg); color: var(--fg); }
main { max-width: 1280px; margin: 0 auto; }
.hero { display: grid; grid-template-columns: 1.1fr 1fr; gap: 48px; padding: 72px 48px 64px; align-items: center; }
.hero-left { display: flex; flex-direction: column; gap: 20px; }
.eyebrow { font-size: 12px; font-weight: 500; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
h1 { font-size: 52px; font-weight: 700; line-height: 1.08; letter-spacing: -0.03em; margin: 0; }
.lede { font-size: 17px; line-height: 1.6; color: var(--muted); max-width: 44ch; margin: 0; }
.cta-row { display: flex; gap: 12px; }
.btn-primary { background: var(--accent); color: var(--onAccent); font-size: 15px; font-weight: 600;
padding: 12px 22px; border-radius: 8px; text-decoration: none; border: 0; cursor: pointer;
display: inline-block; font-family: inherit; }
.btn-primary.disabled { opacity: .45; cursor: not-allowed; }
.btn-secondary { border: 1px solid var(--line2); color: var(--fg); font-size: 15px;
padding: 12px 22px; border-radius: 8px; text-decoration: none; display: inline-block; }
.trust { font-size: 12px; color: var(--faint); }
.hero-right .statuscard { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
padding: 12px; box-shadow: var(--shadow); }
.statuscard-label { font-size: 11px; color: var(--faint); padding: 6px 8px 10px; letter-spacing: .08em; }
.statuscard-body { min-height: 120px; border-radius: 8px; background: var(--inputBg);
border: 1px solid var(--line); padding: 18px; display: flex; flex-direction: column;
justify-content: center; gap: 8px; }
.mockstatus { display: flex; align-items: center; gap: 12px; }
.mockavatar { width: 38px; height: 38px; border-radius: 8px; background: var(--btnBg); }
.mockname { font-weight: 700; font-size: 15px; }
.mocktrack { font-size: 13px; color: var(--accent); margin-top: 2px; }
.mocknote { font-size: 11px; color: var(--faint); }
section.status { display: flex; flex-direction: column; gap: 6px; }
.statusline { margin: 0; font-size: 14px; color: var(--muted); line-height: 1.5; }
.statusline b { color: var(--fg); }
.nowplaying { font-size: 16px; display: flex; align-items: center; gap: 10px; }
.playdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
animation: pulse 1.6s ease-in-out infinite; flex-shrink: 0; }
.mono-tag { font-size: 11px; color: var(--accent); letter-spacing: .08em; margin-right: 6px; }
.warn { color: var(--err); font-size: 13px; margin: 0; }
.privacystrip { margin: 0 48px; background: var(--card); border: 1px solid var(--line2);
border-radius: 12px; padding: 20px 24px; display: flex; gap: 16px; align-items: baseline; }
.privlabel { font-size: 12px; color: var(--accent); letter-spacing: .08em; flex-shrink: 0; }
.privtext { font-size: 14px; color: var(--muted); line-height: 1.6; }
.setup { padding: 56px 48px 8px; }
.setup-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 18px; }
.setup-head h2 { font-size: 24px; margin: 0; }
.setup-time { font-size: 12px; color: var(--faint); }
.setup-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
.card { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
padding: 24px; display: flex; flex-direction: column; gap: 12px; }
.card.locked { opacity: .6; }
.card.done { border-color: color-mix(in srgb, var(--accent) 40%, var(--line2)); }
.step-eyebrow { font-size: 12px; font-weight: 500; color: var(--accent); letter-spacing: .08em; }
.card h3 { font-size: 18px; margin: 0; }
.card ol { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.7; color: var(--muted);
display: flex; flex-direction: column; gap: 6px; }
.card ol b { color: var(--fg); }
.card a { color: var(--fg); }
code { font-size: 13px; background: var(--btnBg); border-radius: 4px; padding: 1px 6px; word-break: break-all; }
.chip { display: inline-flex; align-items: center; gap: 8px; margin-top: 4px; }
.copy { border: 1px solid var(--line2); background: var(--btnBg); color: var(--fg); border-radius: 6px;
padding: 3px 10px; cursor: pointer; font-size: 12px; font-family: inherit; }
.card form { display: flex; flex-direction: column; gap: 10px; margin: 0; }
.card input[type=password], .card input:not([type]), .card input[type=text] {
background: var(--inputBg); border: 1px solid var(--line2); border-radius: 6px;
padding: 11px 12px; font-size: 13px; color: var(--fg); width: 100%; }
.card input::placeholder { color: var(--faint); }
.btn-card { background: var(--btnBg); border: 1px solid var(--line2); color: var(--fg);
font-size: 13px; font-weight: 500; padding: 9px 16px; border-radius: 6px; cursor: pointer;
font-family: inherit; align-self: flex-start; }
.cardnote { font-size: 14px; color: var(--muted); line-height: 1.6; margin: 0; }
.finenote { font-size: 12px; color: var(--faint); margin: 0; }
.finenote-list { font-size: 12px; color: var(--faint); margin: 4px 0 0; padding-left: 18px;
font-family: "IBM Plex Mono", ui-monospace, monospace; line-height: 1.7; }
.finenote-list a { color: var(--muted); }
.okline { color: var(--accent); font-size: 14px; font-weight: 500; margin: 0; }
.okline b { color: var(--fg); }
.linkish { background: none; border: 0; padding: 0; color: var(--muted); text-decoration: underline;
cursor: pointer; font-size: 13px; align-self: flex-start; font-family: inherit; }
.danger-link { color: var(--err); margin-top: 6px; }
.toast { color: var(--accent); font-size: 13px; font-family: "IBM Plex Mono", monospace; margin: 0 0 12px; }
.settings-form { gap: 14px !important; }
.settings-form .inline { display: flex; align-items: center; gap: 8px; font-size: 14px;
color: var(--muted); flex-wrap: wrap; }
.fieldname { font-size: 12px; color: var(--faint); display: block; margin-bottom: 6px;
font-family: "IBM Plex Mono", monospace; letter-spacing: .08em; }
.emojirow { display: flex; gap: 6px; flex-wrap: wrap; }
label.emoji { display: inline-flex; align-items: center; gap: 4px; font-size: 18px;
border: 1px solid var(--line2); border-radius: 8px; padding: 5px 9px; cursor: pointer; }
label.emoji:has(input:checked) { border-color: var(--accent); }
label.emoji input { accent-color: var(--accent); }
select { background: var(--inputBg); color: var(--fg); border: 1px solid var(--line2);
border-radius: 6px; padding: 5px 8px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
.faq { padding: 48px 48px 40px; max-width: 760px; }
.faq h2 { font-size: 24px; margin: 0 0 10px; }
.faq details { border-bottom: 1px solid var(--line); padding: 12px 0; }
.faq details:last-of-type { border-bottom: 0; }
.faq summary { cursor: pointer; font-weight: 500; font-size: 15px; }
.faq p { color: var(--muted); font-size: 14px; line-height: 1.65; margin: 10px 0 2px; }
.wiznav { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 4px; }
.wizprog { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--faint); }
.wiznext { background: var(--accent); color: var(--onAccent); border: 0; font-weight: 600; }
.setup-grid .card ol > li { min-height: 3.2em; }
.donate { background: var(--donate); color: #fff; font-size: 13px; padding: 9px 18px;
border-radius: 6px; text-decoration: none; }
/* Stats page reuse */
table { color: var(--muted); font-size: 14px; } th { color: var(--fg); }
@media (max-width: 900px) {
.hero { grid-template-columns: 1fr; padding: 48px 24px 40px; }
.setup-grid { grid-template-columns: 1fr; }
header.site, footer.site, .setup, .faq { padding-left: 24px; padding-right: 24px; }
.privacystrip { margin: 0 24px; flex-direction: column; gap: 8px; }
h1 { font-size: 38px; }
footer.site { flex-direction: column; gap: 14px; text-align: center; }
}
</style></head><body>
${body}
<script>
(function () {
var current = localStorage.getItem("ts-theme") === "light" ? "light" : "dark";
document.querySelectorAll(".themeseg button").forEach(function (b) {
if (b.dataset.theme === current) b.classList.add("active");
b.addEventListener("click", function () {
localStorage.setItem("ts-theme", b.dataset.theme);
document.documentElement.className = b.dataset.theme === "light" ? "light" : "";
document.querySelectorAll(".themeseg button").forEach(function (x) { x.classList.remove("active"); });
b.classList.add("active");
});
});
})();
</script>
</body></html>`;
}
// ---------- per-user Spotify credentials ----------
async function saveSpotifyCredentials(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await request.formData();
const clientId = (form.get("client_id") || "").trim();
const clientSecret = (form.get("client_secret") || "").trim();
if (!/^[0-9a-f]{32}$/.test(clientId) || !/^[0-9a-f]{32}$/.test(clientSecret)) {
return new Response(page(`<main><h1>Hmm, that doesn't look right</h1>
<p>Both the Client ID and Client secret are exactly 32 characters of
letters and numbers. Double-check you copied the whole thing (and not the
app name), then <a href="/">go back and try again</a>.</p></main>`),
{ status: 400, headers: { "Content-Type": "text/html; charset=utf-8" } });
}
const userId = getUserId(request) || crypto.randomUUID();
const user = (await getUser(env, userId)) || {};
user.spotifyClientId = clientId;
user.spotifyClientSecret = clientSecret;
delete user.spotifyRefreshToken; // new creds invalidate any old connection
await putUser(env, userId, user);
await ensureInIndex(env, userId);
// Straight into the OAuth flow — one less click.
return withUserCookie(redirect("/auth/spotify"), userId);
}
// Lets a stuck user re-enter mistyped credentials (step 2 reopens).
async function resetSpotify(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const userId = getUserId(request);
const user = await getUser(env, userId);
if (user) {
delete user.spotifyClientId;
delete user.spotifyClientSecret;
delete user.spotifyRefreshToken;
await putUser(env, userId, user);
}
return redirect("/");
}
// ---------- settings ----------
async function saveSettings(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const userId = getUserId(request);
const user = await getUser(env, userId);
if (!user) return redirect("/");
const form = await request.formData();
user.paused = form.get("paused") === "on";
const emoji = form.get("emoji");
user.emoji = EMOJI_CHOICES.includes(emoji) ? emoji : DEFAULT_EMOJI;
if (form.get("use_hours") === "on") {
const start = parseInt(form.get("start"), 10);
const end = parseInt(form.get("end"), 10);
const tz = (form.get("tz") || "").trim();
const valid =
Number.isInteger(start) && Number.isInteger(end) &&
start >= 0 && start < 24 && end >= 0 && end < 24 &&
start !== end && hourIn(tz) !== null;
user.workHours = valid ? { start, end, tz } : null;
} else {
user.workHours = null;
}
await putUser(env, userId, user);
return redirect("/?saved=1");
}
// ---------- Spotify OAuth (against the user's own app) ----------
async function authSpotify(request, env) {
const user = await getUser(env, getUserId(request));
if (!user?.spotifyClientId) return redirect("/"); // step 2 not done yet
const state = crypto.randomUUID().replaceAll("-", "");
const params = new URLSearchParams({
response_type: "code",
client_id: user.spotifyClientId,
scope: SPOTIFY_SCOPE,
redirect_uri: `${baseUrl(request)}/callback/spotify`,
show_dialog: "true",
state,
});
return redirectWithState(`https://accounts.spotify.com/authorize?${params}`, state);
}
async function callbackSpotify(request, env) {
const code = new URL(request.url).searchParams.get("code");
if (!code) return new Response("Spotify authorization was denied.", { status: 400 });
if (!stateMatches(request)) return new Response("OAuth state mismatch — please start again from the main page.", { status: 400 });
const userId = getUserId(request);
const user = await getUser(env, userId);
if (!user?.spotifyClientId) return redirect("/");
const { id, secret } = spotifyCreds(env, user);
const res = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic " + btoa(`${id}:${secret}`),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${baseUrl(request)}/callback/spotify`,
}),
});
const tokens = await res.json();
if (!tokens.refresh_token) {
return new Response(page(`<main><h1>Spotify connection failed</h1>
<p>Spotify said: <code>${esc(JSON.stringify(tokens.error || tokens))}</code></p>
<p>The two common causes: the <b>Client secret</b> was copied wrong (use the
"Typo?" link on the main page to re-enter it), or the <b>Redirect URI</b> in
your Spotify app doesn't match exactly. Fix whichever it is, then
<a href="/">try again</a>.</p></main>`),
{ status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
}
user.spotifyRefreshToken = tokens.refresh_token;
await putUser(env, userId, user);
await ensureInIndex(env, userId);
return withUserCookie(redirect("/"), userId);
}
// ---------- Slack: bring-your-own-app token ----------
async function saveSlackToken(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const userId = getUserId(request) || crypto.randomUUID();
const form = await request.formData();
const token = (form.get("slack_token") || "").trim();
const fail = (msg) =>
new Response(page(`<main><h1>Slack token check failed</h1><p>${msg}</p>
<p><a href="/">Go back and try again</a>.</p></main>`),
{ status: 400, headers: { "Content-Type": "text/html; charset=utf-8" } });
if (!/^xoxp-[A-Za-z0-9-]+$/.test(token)) {
return fail(`That doesn't look like a user token — it must start with <code>xoxp-</code>.
Make sure you copied the <b>User</b> OAuth Token, not the Bot token (<code>xoxb-</code>).`);
}
// Verify the token is alive and has both scopes before saving.
const auth = await slackApi(token, "auth.test", {});
if (!auth.ok) return fail(`Slack rejected the token: <code>${esc(auth.error)}</code>.`);
const prof = await slackApi(token, "users.profile.get", {});
if (!prof.ok) {
return fail(prof.error === "missing_scope"
? `The token is missing the <code>users.profile:read</code> scope. Add both scopes under
<b>User Token Scopes</b>, click <b>Reinstall to Workspace</b>, and paste the new token.`
: `Slack error: <code>${esc(prof.error)}</code>.`);
}
const probe = await slackApi(token, "users.profile.set", { profile: {} });
if (!probe.ok && probe.error === "missing_scope") {
return fail(`The token is missing the <code>users.profile:write</code> scope. Add both scopes under
<b>User Token Scopes</b>, click <b>Reinstall to Workspace</b>, and paste the new token.`);
}
const user = (await getUser(env, userId)) || {};
user.slackUserToken = token;
user.slackName = prof.profile?.display_name || prof.profile?.real_name || auth.user || "";
user.reauthNeeded = false;
await putUser(env, userId, user);
await ensureInIndex(env, userId);
return withUserCookie(redirect("/"), userId);
}
// ---------- disconnect ----------
async function disconnect(request, env) {
const userId = getUserId(request);
if (userId) {
const user = await getUser(env, userId);
if (user?.slackUserToken) {
// Best effort: clear their status before forgetting them — but only if
// it's one of ours. A hand-set status survives disconnecting.
try {
const current = await getOwnStatus(user.slackUserToken).catch(() => null);
const isOurs = current && EMOJI_CHOICES.includes(current.emoji);
const unknown = current === null; // old token — keep previous behavior
if (isOurs || unknown) await setSlackStatus(user.slackUserToken, null);
} catch {}
}
await env.USERS.delete(`user:${userId}`);
await removeFromIndex(env, userId);
}
return redirect("/");
}
// ---------- the cron sync ----------
// Free-plan budget: ~50 subrequests per invocation, ~5 per user. To keep the
// every-minute cadence at any roster size, the cron fans out: each batch of
// users is dispatched as its own invocation (self-request), and every
// invocation gets a fresh subrequest budget.
const USERS_PER_BATCH = 8;
async function syncAllUsers(env) {
const keys = await getUserIndex(env);
if (keys.length <= USERS_PER_BATCH) {
await Promise.allSettled(keys.map((name) => syncOneUser(env, name)));
return;
}
const batches = [];
for (let i = 0; i < keys.length; i += USERS_PER_BATCH) {
batches.push(keys.slice(i, i + USERS_PER_BATCH));
}
await Promise.allSettled(
batches.map((batch) =>
fetch(`${env.SELF_URL}/sync-batch`, {
method: "POST",
headers: { "X-Sync-Key": env.STATS_KEY, "Content-Type": "application/json" },
body: JSON.stringify(batch),
})
)
);
}
// Internal endpoint the cron fans out to — one invocation per batch.
async function syncBatch(request, env) {
if (request.method !== "POST" || request.headers.get("X-Sync-Key") !== env.STATS_KEY) {
return new Response("Not found", { status: 404 }); // indistinguishable from unknown routes
}
const names = await request.json();
if (!Array.isArray(names)) return new Response("bad request", { status: 400 });
await Promise.allSettled(names.slice(0, USERS_PER_BATCH).map((n) => syncOneUser(env, String(n))));
return new Response("ok");
}
async function syncOneUser(env, name) {
const user = await env.USERS.get(name, "json");
if (!user?.spotifyRefreshToken || !user?.slackUserToken) return;
let dirty = false;
try {
// Paused or outside work hours behaves exactly like "nothing playing".
const active = !user.paused && withinWorkHours(user);
const track = active
? await getCurrentTrack(env, user, () => (dirty = true))
: null;
// Manual-status protection: never touch a status a human set by hand.
let current = null; // null = can't read (old token)
try {
current = await getOwnStatus(user.slackUserToken);
} catch (err) {
if (err.slackError !== "missing_scope") throw err;
}
const canRead = current !== null;
if (user.reauthNeeded !== !canRead) {
user.reauthNeeded = !canRead;
dirty = true;
}
const isEmpty = canRead && !current.emoji && !current.text;
const isOurs = canRead && EMOJI_CHOICES.includes(current.emoji);
const isManual = canRead && !isEmpty && !isOurs;
// Skip the Slack write when the status is already correct and its
// expiration isn't close — the single biggest subrequest saver.
const wantText = track ? track.slice(0, 100) : null;
const secondsLeft = canRead ? current.expiration - Math.floor(Date.now() / 1000) : 0;
const alreadyCorrect =
canRead && isOurs && wantText !== null &&
current.text === wantText && secondsLeft > 120;
if (!isManual) {
if (track && !alreadyCorrect) {
await setSlackStatus(user.slackUserToken, track, user.emoji);
} else if (!track && (canRead ? isOurs : user.lastTrack)) {
await setSlackStatus(user.slackUserToken, null);
}
}
// Remember the manual status so the page can explain why we're standing by.
const manualStatus = isManual ? `${current.emoji} ${current.text}`.trim() : null;
if ((user.manualStatus ?? null) !== manualStatus) {
user.manualStatus = manualStatus;
dirty = true;
}
if (track !== (user.lastTrack ?? null)) {
user.lastTrack = track;
if (track) user.lastPlayedAt = Date.now();
dirty = true;
}
if (user.lastError) {
user.lastError = null;
dirty = true;
}
} catch (err) {
console.log("sync failed for", name, "-", err.message);
if (user.lastError !== err.message) {
user.lastError = err.message;
dirty = true;
}
}
if (dirty) await env.USERS.put(name, JSON.stringify(user));
}
async function getCurrentTrack(env, user, onTokenRotated) {
const { id, secret } = spotifyCreds(env, user);
const tokenRes = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic " + btoa(`${id}:${secret}`),
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: user.spotifyRefreshToken,
}),
});
const tokenData = await tokenRes.json();
const access_token = tokenData.access_token;
if (!access_token) {
throw new Error("Spotify sign-in expired: " + (tokenData.error_description || tokenData.error || "unknown"));
}
// Spotify refresh tokens have a 180-day lifetime and may be rotated on use —
// keep the newest one or the connection dies at the expiry mark.
if (tokenData.refresh_token && tokenData.refresh_token !== user.spotifyRefreshToken) {
user.spotifyRefreshToken = tokenData.refresh_token;
onTokenRotated?.();
}
const res = await fetch("https://api.spotify.com/v1/me/player/currently-playing", {
headers: { Authorization: `Bearer ${access_token}` },
});
if (res.status === 204) return null;
if (!res.ok) throw new Error(`Spotify currently-playing returned ${res.status}`);
const data = await res.json();
if (!data.is_playing || !data.item || data.item.type !== "track") return null;
const artists = data.item.artists.map((a) => a.name).join(", ");
return `${data.item.name} — ${artists}`;
}
async function setSlackStatus(token, text, emoji) {
const profile = text
? {
status_emoji: emoji || DEFAULT_EMOJI,
status_text: text.slice(0, 100),
status_expiration: Math.floor(Date.now() / 1000) + STATUS_EXPIRATION_S,
}
: { status_emoji: "", status_text: "", status_expiration: 0 };
const result = await slackApi(token, "users.profile.set", { profile });
if (!result.ok) {
throw new Error("Slack status update failed: " + result.error);
}
}
async function getOwnStatus(token) {
const res = await slackApi(token, "users.profile.get", {});
if (!res.ok) {
const err = new Error("Slack profile read failed: " + res.error);
err.slackError = res.error;
throw err;
}
return {
text: res.profile?.status_text || "",
emoji: res.profile?.status_emoji || "",
expiration: res.profile?.status_expiration || 0,
};
}
async function slackApi(token, method, body) {
const res = await fetch(`https://slack.com/api/${method}`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
return res.json();
}
// ============================================================================
// Local runtime — plain Node (18+): storage file, web server, sync timer.
// Storage: ~/.tunestatus-local.json · Sync: every 30s · UI: http://127.0.0.1:8898
// ============================================================================
const nodeHttp = require("node:http");
const nodeFs = require("node:fs");
const nodeOs = require("node:os");
const nodePath = require("node:path");
const { execFileSync, execFile } = require("node:child_process");
const DATA_PATH = nodePath.join(nodeOs.homedir(), ".tunestatus-local.json");
const PORT = 8898;
const SYNC_INTERVAL_MS = 30_000;
function loadStore() {
try { return JSON.parse(nodeFs.readFileSync(DATA_PATH, "utf8")); } catch { return {}; }
}
function persistStore(store) {
nodeFs.writeFileSync(DATA_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
}
// Simple key-value storage over a local JSON file.
const store = loadStore();
const env = {
USERS: {
async get(key, type) {
const v = store[key];
if (v === undefined) return null;
return type === "json" ? v : JSON.stringify(v);
},
async put(key, value) {
store[key] = JSON.parse(value);
persistStore(store);
},
async delete(key) {
delete store[key];
persistStore(store);
},
async list({ prefix } = {}) {
return { keys: Object.keys(store).filter((k) => !prefix || k.startsWith(prefix)).map((name) => ({ name })) };
},
},
SELF_URL: `http://127.0.0.1:${PORT}`,
STATS_KEY: undefined, // /stats and /sync-batch stay disabled locally
};
async function handleFetch(request) {
const url = new URL(request.url);
try {
switch (url.pathname) {
case "/": return home(request, env);
case "/spotify-credentials": return saveSpotifyCredentials(request, env);
case "/reset-spotify": return resetSpotify(request, env);
case "/auth/spotify": return authSpotify(request, env);
case "/callback/spotify": return callbackSpotify(request, env);
case "/slack-token": return saveSlackToken(request, env);
case "/settings": return saveSettings(request, env);
case "/disconnect": return disconnect(request, env);
case "/panel": return panel(request, env);
case "/count": return userCount(env);
case "/autostart": return autostartRoute(request);
default: return new Response("Not found", { status: 404 });
}
} catch (err) {
return new Response("Error: " + err.message, { status: 500 });
}
}
const server = nodeHttp.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = chunks.length ? Buffer.concat(chunks) : undefined;
const request = new Request(`http://127.0.0.1:${PORT}${req.url}`, {
method: req.method,
headers: req.headers,
body: body && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
});
const response = await handleFetch(request);
const headers = {};
response.headers.forEach((v, k) => { if (k !== "set-cookie") headers[k] = v; });
const cookies = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
res.writeHead(response.status, { ...headers, ...(cookies.length ? { "Set-Cookie": cookies } : {}) });
const buf = Buffer.from(await response.arrayBuffer());
res.end(buf);
});
// ---- autostart helpers (macOS launchd; prints instructions elsewhere) ----
const PLIST_PATH = nodePath.join(nodeOs.homedir(), "Library/LaunchAgents/com.tunestatus.local.plist");
function installAutostart() {
if (process.platform !== "darwin") {
console.log("Autostart install is automated on macOS only.");
console.log("Windows: Task Scheduler → run `node " + __filename + "` at logon.");
console.log("Linux: create a systemd user service running `node " + __filename + "`.");
return;
}
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.tunestatus.local</string>
<key>ProgramArguments</key><array>
<string>${process.execPath}</string>
<string>${__filename}</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict></plist>
`;
nodeFs.mkdirSync(nodePath.dirname(PLIST_PATH), { recursive: true });
nodeFs.writeFileSync(PLIST_PATH, plist);
try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "ignore" }); } catch {}
execFileSync("launchctl", ["load", PLIST_PATH]);
console.log("✓ TuneStatus will now start automatically at login.");
console.log(" (undo anytime with: node tunestatus.js uninstall-autostart)");
}
function uninstallAutostart() {
if (process.platform !== "darwin") { console.log("Nothing to do on this platform."); return; }
try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "ignore" }); } catch {}
try { nodeFs.unlinkSync(PLIST_PATH); } catch {}
console.log("✓ Autostart removed.");
}
function isAutostartInstalled() {
try { return nodeFs.existsSync(PLIST_PATH); } catch { return false; }
}
async function autostartRoute(request) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await request.formData();
try {
if (form.get("action") === "uninstall") uninstallAutostart();
else installAutostart();
} catch (e) {
return new Response("Could not change autostart: " + e.message, { status: 500 });
}
return redirect("/#sync");
}
// ---- main ----
const cmd = process.argv[2];
if (cmd === "install-autostart") { installAutostart(); process.exit(0); }
if (cmd === "uninstall-autostart") { uninstallAutostart(); process.exit(0); }
server.listen(PORT, "127.0.0.1", () => {
const url = `http://127.0.0.1:${PORT}`;
console.log(`♪ TuneStatus (local) — ${url}`);
console.log(` Config: ${DATA_PATH}`);
console.log(` Tip: \`node tunestatus.js install-autostart\` starts it at login (macOS).`);
// Open the browser on first run (no config yet).
if (!Object.keys(store).some((k) => k.startsWith("user:"))) {
try {
if (process.platform === "win32") execFile("cmd", ["/c", "start", "", url]);
else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url]);
} catch {}
}
});
setInterval(() => syncAllUsers(env).catch((e) => console.log("sync error:", e.message)), SYNC_INTERVAL_MS);
syncAllUsers(env).catch(() => {});