// Spotify Song Requests for Streamer.bot // One source file shared by three actions; Execute() dispatches on the action name: // "Spotify SR - Settings" opens the settings window (connect Spotify, costs, limits) // "Spotify SR - Tick" timer every few seconds: feeds the request queue into Spotify // "Spotify SR - Commands" chat commands + channel point redemptions // Viewers' requests wait in our own queue and are handed to Spotify one at a time, about half a // minute before the current song ends. That keeps the queue fair (round-robin per viewer) // and lets requests be removed and refunded, which Spotify's own queue can't do. using System; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.WinForms; using Newtonsoft.Json.Linq; public class CPHInline { // Defaults until the settings window saves a config. const string DEFAULT_CURRENCY_VAR = ""; const string DEFAULT_CURRENCY_NAME = "points"; const long DEFAULT_COST = 0; const int DEFAULT_COOLDOWN = 30; const string DEFAULT_FREE_USERS = ""; const int CALLBACK_PORT = 48123; const string REDIRECT_URI = "http://127.0.0.1:48123/callback"; const string SCOPES = "user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-modify-private playlist-modify-public"; const string VERSION = "1.9.0"; const string G_CONFIG = "spotifySR_config"; const string G_STATE = "spotifySR_state"; const string G_ACCESS = "spotifySR_accessToken"; const string G_REFRESH = "spotifySR_refreshToken"; const string G_EXPIRES = "spotifySR_tokenExpires"; const string G_ACCOUNT = "spotifySR_account"; const string U_LAST = "spotifySR_lastRequest"; // The next request goes to Spotify this long before the current song ends: enough ticks (every 5 s) to get // through a network blip, and well before Spotify lines up its own next song. const int PUSH_BEFORE_END_MS = 30000; // A song of Spotify's that started less than this long ago can still be swapped for a request that missed its turn. const int CATCH_UP_MS = 15000; // A skip with a request just handed over waits for Spotify to list it (checking this often, up to 8 times), or the // skip can land on the song Spotify had lined up before the request arrived. const int QUEUE_POLL_MS = 250; // What the last tick saw playing (uri, ms left, when, playing?) - only for this session, not saved. const string G_SEEN = "spotifySR_seen"; // A catch-up that couldn't skip yet (Spotify's queue list lags a moment after a song is added): retried next tick. const string G_CATCHUP = "spotifySR_catchup"; // Spotify fades into the next song this early at most (crossfade is up to 12 s). const int CROSSFADE_MS = 13000; // Streamer.bot switches imported timers off. Without the tick nothing goes to Spotify on its own (only on a skip) and // nothing ever sees a request start, so it sits in "up next" after it played. The tick stamps G_TICK_AT; the other // actions and the window switch the timer back on when it has gone quiet (at most every 30 s, G_TICK_NUDGE). const string G_TICK_AT = "spotifySR_tickAt"; const string G_TICK_NUDGE = "spotifySR_tickNudge"; const string TICK_TIMER = "Spotify SR Tick"; const string TICK_TIMER_ID = "5f0a1c2e-3b4d-4e5f-8a6b-7c8d9e0f1a02"; // its id in the import string // Spotify gives development-mode apps a daily request quota, shared by every app on the developer's account. Once // it's used up every call gets 429 QUOTA_EXCEEDED for hours, and the now-playing overlay froze on the last song. // So: after a 429 nothing calls Spotify until its Retry-After has passed (G_BACKOFF, unix seconds, saved so a // restart doesn't start hammering again), and the tick only asks Spotify as often as it needs to (NextPollMs). const string G_BACKOFF = "spotifySR_backoffUntil"; const string G_NEXT_POLL = "spotifySR_nextPoll"; // unix ms; ticks before this don't call Spotify const string G_IDLE_SINCE = "spotifySR_idleSince"; // unix ms; when the tick first saw nothing playing // Podcasts and audiobooks only show up when asked for (additional_types), otherwise Spotify reports no song at all. const string NOW_PATH = "/me/player/currently-playing?market=from_token&additional_types=episode"; // YouTube links play on the streamer's YouTube player: a hosted page added to OBS as a browser source (audio only). // It reaches this Streamer.bot over the WebSocket server like the dock: it checks in every 15 s ("yt-hello"), // plays what it's told ("yt-play"/"yt-stop" broadcasts) and reports back ("yt-state", "yt-ended", "yt-error"). const string YT_PLAYER_URL = "https://byttenapple.com/assets/song-requests/youtube"; const string G_YT_PLAYER = "spotifySR_ytPlayer"; // the player in charge: {id, at (unix ms), version} const string G_YT_NOW = "spotifySR_ytNow"; // what it last reported: {videoId, progressMs, durationMs, isPlaying, at} const string G_YT_START = "spotifySR_ytStartAt"; // unix ms a start at the end of the Spotify song is set for (0 = none) const int YT_PLAYER_GONE_MS = 90000; // no check-in for this long: the player is closed (a background browser tab may only check in once a minute) // Serializes every read-modify-write of the queue state: the actions, and the queue window // (which runs on its own thread outside Streamer.bot's action queue). const string STATE_MUTEX = "StreamerBot_SpotifySR_State"; public bool Execute() { string actionName = Arg("actionName"); try { if (actionName.IndexOf("Tick", StringComparison.OrdinalIgnoreCase) < 0) KeepTickOn(); if (actionName.IndexOf("Settings", StringComparison.OrdinalIgnoreCase) >= 0) { // From chat only the broadcaster may open it; run from Streamer.bot directly, anyone can. if (Arg("command") != "" && Role() < 4) return true; OpenSettings(); return true; } using (var mutex = new Mutex(false, STATE_MUTEX)) { bool owned = false; try { try { owned = mutex.WaitOne(15000); } catch (AbandonedMutexException) { owned = true; } if (!owned) CPH.LogWarn("[SpotifySR] state lock timed out, running anyway"); return Dispatch(actionName); } finally { if (owned) mutex.ReleaseMutex(); } } } catch (Exception ex) { CPH.LogError("[SpotifySR] " + ex); } return true; } bool Dispatch(string actionName) { if (actionName.IndexOf("Tick", StringComparison.OrdinalIgnoreCase) >= 0) { CPH.SetGlobalVar(G_TICK_AT, Now(), false); Tick(); return true; } if (actionName.IndexOf("Panel", StringComparison.OrdinalIgnoreCase) >= 0) { JObject m; try { m = JObject.Parse(Arg("panel")); } catch { return true; } PanelCommand(m, Broadcast); return true; } if (Arg("redemptionId") != "") return Request(true); switch (Arg("command").ToLowerInvariant()) { case "!sr": case "!sq": case "!songrequest": return Request(false); case "!song": case "!nowplaying": return NowPlaying(); case "!queue": case "!songqueue": return ShowQueue(); case "!wrongsong": case "!removesong": return WrongSong(); case "!skip": return Skip(); case "!srclear": case "!sqclear": return Clear(); case "!sron": return Toggle(true); case "!sroff": return Toggle(false); case "!voteskip": return VoteSkip(); case "!wheresmysong": return WhereIsMySong(); case "!srban": return BanNow(); case "!srreset": if (!IsMod()) return true; string forgot = ResetStuck(); Tell("reset", V("forgot", forgot != "" ? " (stopped waiting for " + forgot + ")" : "")); return true; } return true; } // ───────────────────────── chat side ───────────────────────── bool Request(bool isReward) { string user = UserName(); string userId = Arg("userId"); string input = Arg("rawInput").Trim(); string rewardId = Arg("rewardId"); string redemptionId = Arg("redemptionId"); var cfg = LoadConfig(); var st = LoadState(); var v = V("user", user); Func fail = key => { Tell(key, v, user); if (isReward) RefundRedemption(rewardId, redemptionId); return false; }; bool broadcaster = Role() >= 4, mod = Role() >= 3, subOrVip = IsSub() || IsVip(); if (!Connected()) return fail("not_set_up"); if (!(bool)st["enabled"]) return fail("closed"); string access = (string)cfg["access"]; if (access == "mods" && !mod) return fail("access_mods"); if (access == "subs" && !mod && !subOrVip) return fail("access_subs"); // what this viewer pays and may have waiting: subscribers and VIPs can get their own price and limit long cost = (long)cfg["cost"]; var special = new List(); if (IsSub() && (long)cfg["costSub"] >= 0) special.Add((long)cfg["costSub"]); if (IsVip() && (long)cfg["costVip"] >= 0) special.Add((long)cfg["costVip"]); if (special.Count > 0) cost = special.Min(); long maxPerUser = subOrVip && (long)cfg["maxPerUserSub"] >= 0 ? (long)cfg["maxPerUserSub"] : (long)cfg["maxPerUser"]; string currencyVar = (string)cfg["currencyVar"]; v["cost"] = cost.ToString(); v["currency"] = (string)cfg["currencyName"]; v["costnote"] = cost > 0 && currencyVar != "" ? " | costs " + cost + " " + cfg["currencyName"] : ""; if (input == "") return fail("usage"); bool onYouTubePlayer = YouTubeId(input) != null && (string)cfg["youtube"] != "spotify" && YtPlayer() != null; if (BackoffLeft() > 0 && !onYouTubePlayer) { v["wait"] = HowLong(BackoffLeft()); return fail("spotify_paused"); } long now = Now(); if (!isReward && !broadcaster) { long cooldown = (long)cfg["cooldown"]; long last = UserLong(userId, U_LAST); if (cooldown > 0 && now - last < cooldown) { v["seconds"] = (cooldown - (now - last)).ToString(); return fail("cooldown"); } } var queue = (JArray)st["queue"]; int mine = queue.Count(e => (string)e["userId"] == userId); if (!broadcaster && maxPerUser > 0 && mine >= maxPerUser) { v["count"] = mine.ToString(); v["songs"] = Plural(mine, "song"); return fail("max_per_user"); } string err; var track = Resolve(input, v, out err); if (track == null) return fail(err); TrackVars(track, v); string uri = (string)track["uri"]; long durMs = (long?)track["duration_ms"] ?? 0; long maxMinutes = (long)cfg["maxMinutes"]; if (maxMinutes > 0 && durMs > maxMinutes * 60000) { v["max"] = maxMinutes.ToString(); return fail("too_long"); } if (track["is_playable"] != null && track["is_playable"].Type == JTokenType.Boolean && !(bool)track["is_playable"]) return fail("not_playable"); string bannedArtist; string refusal = Refusal(track, cfg, out bannedArtist); if (refusal != null) { if (bannedArtist != null) v["artist"] = bannedArtist; return fail(refusal); } var pushed = st["pushed"] as JObject; if (queue.Any(e => (string)e["uri"] == uri) || (pushed != null && (string)pushed["uri"] == uri)) return fail("in_queue"); if (PlayingNow(uri)) return fail("playing_now"); long noRepeat = (long)cfg["noRepeatMinutes"]; long lastPlayed = noRepeat > 0 ? LastPlayed(track) : 0; if (lastPlayed > 0 && now - lastPlayed < noRepeat * 60) { v["ago"] = HowLong(now - lastPlayed); v["wait"] = HowLong(noRepeat * 60 - (now - lastPlayed)); return fail("repeat"); } long paid = 0; if (!isReward && cost > 0 && currencyVar != "" && !broadcaster && !IsFreeUser(cfg, user)) { long balance = UserVarLong(userId, currencyVar); if (balance < cost) { v["balance"] = balance.ToString(); return fail("not_enough"); } CPH.SetTwitchUserVarById(userId, currencyVar, balance - cost, true); paid = cost; } if (!isReward) CPH.SetTwitchUserVarById(userId, U_LAST, now.ToString(), true); long minRound = queue.Count == 0 ? 0 : queue.Min(e => (long)e["round"]); long myMax = queue.Where(e => (string)e["userId"] == userId).Select(e => (long)e["round"]).DefaultIfEmpty(-1).Max(); long seq = (long)st["seq"] + 1; st["seq"] = seq; // Round-robin: a viewer's Nth waiting song goes after everyone's (N-1)th. long newRound = Math.Max(myMax + 1, minRound); int insertAt = queue.Count; for (int i = 0; i < queue.Count; i++) { if ((long)queue[i]["round"] > newRound) { insertAt = i; break; } } var entry = new JObject { ["uri"] = uri, ["name"] = (string)track["name"], ["artists"] = Artists(track), ["url"] = (string)track.SelectToken("external_urls.spotify") ?? (string)track["url"] ?? "", ["image"] = AlbumImage(track), ["durationMs"] = durMs, ["user"] = user, ["userId"] = userId, ["paid"] = paid, ["rewardId"] = isReward ? rewardId : "", ["redemptionId"] = isReward ? redemptionId : "", ["round"] = newRound, ["seq"] = seq, ["requestedAt"] = now }; if (IsYouTube(track)) { entry["source"] = "youtube"; entry["videoId"] = track["videoId"]; } queue.Insert(insertAt, entry); SaveState(st); PollSoon(); // the next tick checks whether it's time to hand it over v["position"] = (insertAt + 1 + (pushed != null ? 1 : 0)).ToString(); Tell("added", v, user); return true; } bool IsSub() { return Arg("isSubscribed").Equals("True", StringComparison.OrdinalIgnoreCase); } bool IsVip() { return Arg("isVip").Equals("True", StringComparison.OrdinalIgnoreCase) || Role() == 2; } static List TrackArtistNames(JObject t) { var a = t["artists"] as JArray; if (a != null) return a.Select(x => (string)x["name"] ?? "").ToList(); return ((string)t["artists"] ?? "").Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries).ToList(); } // A banned song, a banned artist, or explicit while explicit songs are off: the message key, else null. string Refusal(JObject track, JObject cfg, out string bannedArtist) { bannedArtist = null; string uri = (string)track["uri"]; string name = Norm((string)track["name"]); var names = TrackArtistNames(track).Where(x => Norm(x) != "").ToList(); var artists = names.Select(Norm).ToList(); foreach (JObject b in (JArray)cfg["blockSongs"]) { // the same song under another id (another album, its country's version) counts too if ((string)b["uri"] == uri || (name != "" && Norm((string)b["name"]) == name && artists.Any(a => Norm((string)b["artists"]).Contains(a)))) return "blocked_song"; } foreach (var b in ((string)cfg["blockArtists"] ?? "").Split(new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) { int i = artists.IndexOf(Norm(b)); if (Norm(b).Length >= 2 && i >= 0) { bannedArtist = names[i]; return "blocked_artist"; } } if ((bool)cfg["blockExplicit"] && (bool?)track["explicit"] == true) return "explicit"; return null; } // ───────────────────────── history: no repeats ───────────────────────── // Requests that started playing in the last two days, so "no repeats within N minutes" can be checked. const string G_HISTORY = "spotifySR_history"; static string SongKey(JObject t) { return Norm((string)t["name"]) + "|" + Norm(TrackArtistNames(t).FirstOrDefault() ?? ""); } JArray History() { try { return JArray.Parse(CPH.GetGlobalVar(G_HISTORY, true) ?? "[]"); } catch { return new JArray(); } } void RememberPlayed(JObject e) { long now = Now(); var h = new JArray(History().Where(x => now - ((long?)x["at"] ?? 0) < 48 * 3600).Reverse().Take(300).Reverse()); h.Add(new JObject { ["uri"] = e["uri"], ["key"] = SongKey(e), ["at"] = now }); CPH.SetGlobalVar(G_HISTORY, h.ToString(Newtonsoft.Json.Formatting.None), true); } // When this song last started playing as a request (unix seconds), or 0. long LastPlayed(JObject track) { string uri = (string)track["uri"], key = SongKey(track); return History().Where(x => (string)x["uri"] == uri || (key != "|" && (string)x["key"] == key)).Select(x => (long?)x["at"] ?? 0).DefaultIfEmpty(0).Max(); } // Is this song on Spotify right now (as last seen by the tick, at most half a minute ago)? bool PlayingNow(string uri) { try { var g = JObject.Parse(CPH.GetGlobalVar(G_NOW, false) ?? "{}"); var now = g["now"] as JObject; long at = (long?)g["at"] ?? 0; return now != null && (string)now["uri"] == uri && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - at < 30000; } catch { return false; } } bool NowPlaying() { if (!Connected()) return true; var yt = LoadState()["playing"] as JObject; if (IsYouTube(yt)) { Tell("song_request", TrackVars(yt, V("user", (string)yt["user"]))); return true; } int code; var cp = Api("GET", NOW_PATH, null, out code); var item = cp == null ? null : cp["item"] as JObject; if (item == null) { Tell("song_nothing", null); return true; } var st = LoadState(); var playing = st["playing"] as JObject; bool requested = playing != null && (string)playing["uri"] == (string)item["uri"]; Tell(requested ? "song_request" : "song_own", TrackVars(item, V("user", requested ? (string)playing["user"] : ""))); return true; } bool ShowQueue() { var st = LoadState(); var list = new List(); var pushed = st["pushed"] as JObject; if (pushed != null) list.Add(pushed); list.AddRange(((JArray)st["queue"]).Cast()); if (list.Count == 0) { Tell("queue_empty", null); return true; } var sb = new StringBuilder(); for (int i = 0; i < list.Count && i < 5; i++) { string part = (i + 1) + ". " + Label(list[i]) + " (" + list[i]["user"] + ")"; if (sb.Length + part.Length > 440) break; if (i > 0) sb.Append(" | "); sb.Append(part); } if (list.Count > 5) sb.Append(" | +" + (list.Count - 5) + " more"); Tell("queue_list", V("list", sb.ToString())); return true; } bool WrongSong() { string user = UserName(); string userId = Arg("userId"); var st = LoadState(); var queue = (JArray)st["queue"]; var last = queue.Cast().Where(e => (string)e["userId"] == userId).OrderByDescending(e => (long)e["seq"]).FirstOrDefault(); if (last == null) { Tell("wrongsong_none", null, user); return true; } queue.Remove(last); SaveState(st); Refund(last, LoadConfig()); Tell("wrongsong_removed", TrackVars(last, V("refund", RefundNote(last, LoadConfig()))), user); return true; } // A YouTube request playing, or one next in line: the skip is the YouTube player's. Call with the state lock held. // Returns null when it's Spotify's skip, else the message key for what happened. string SkipYouTube(JObject cfg) { var st = LoadState(); if (IsYouTube(st["playing"] as JObject)) { FinishYouTube(st, cfg, null); return "skipped"; } var queue = (JArray)st["queue"]; if (queue.Count > 0 && IsYouTube((JObject)queue[0]) && !(st["pushed"] is JObject)) { if (YtPlayer() == null) return "skip_yt_no_player"; return StartYouTube(st, cfg, true, false) ? "skipped" : null; } return null; } bool Skip() { if (!IsMod()) return true; int code; string key = SkipNow(true, out code); Tell(key, V("code", code.ToString())); return true; } // Skips what's playing: a YouTube request, or Spotify (landing on the next request). byStreamer: you or a mod, so a // request cut short early is refunded. Call with the state lock held. Returns the message key for what happened. string SkipNow(bool byStreamer, out int code) { code = 0; SettleBeforeSkip(byStreamer); var cfg = LoadConfig(); string yt = SkipYouTube(cfg); if (yt != null) return yt; HandNextForSkip(cfg); Api("POST", "/me/player/next" + DeviceQuery(cfg, "?"), "", out code); if (code == 404 && DeviceId(cfg) != "") Api("POST", "/me/player/next", "", out code); PollSoon(); return code >= 200 && code < 300 ? "skipped" : code == 404 ? "skip_no_device" : "skip_failed"; } // ───────────────────────── early skips are refunded ───────────────────────── // A request you or a mod cut short (!skip, the Skip button, skipping or picking another song in Spotify, pressing // play in Spotify during a YouTube request) before it played refundSkipPercent of its length gets refunded. Each // request is settled once: refunded then, or counted as played. A channel point redemption is only fulfilled at // that point, so it can still be cancelled until then. !voteskip and !srban never refund. // What a viewer gets back for a request: "3 apples", "your channel points", or "" when it was free. static string RefundWhat(JObject e, JObject cfg) { long paid = (long?)e["paid"] ?? 0; if (paid > 0) return paid + " " + cfg["currencyName"]; return string.IsNullOrEmpty((string)e["redemptionId"]) ? "" : "your channel points"; } // played: how much of it played (0 to 1, -1 unknown). byStreamer: you or a mod cut it short. void Settle(JObject e, double played, bool byStreamer, JObject cfg) { if (e == null || (bool?)e["settled"] == true) return; e["settled"] = true; long pct = (long)cfg["refundSkipPercent"]; string what = RefundWhat(e, cfg); if (byStreamer && pct > 0 && played >= 0 && played * 100 < pct && what != "") { Refund(e, cfg); CPH.LogInfo("[SpotifySR] " + Label(e) + " was skipped " + (int)(played * 100) + "% in; refunded " + e["user"]); Tell("skip_refund", TrackVars(e, V("refund", what)), (string)e["user"]); } else FulfillRedemption(e); } // How much of the request playing now has played (0 to 1), or -1 when what's on now isn't it. static double PlayedShare(JObject playing, JObject now) { if (playing == null || now == null || !IsSameSong(playing, now)) return -1; long dur = (long?)now["durationMs"] ?? 0; if (dur <= 0) dur = (long?)playing["durationMs"] ?? 0; return dur > 0 ? Math.Min(1.0, Math.Max(0, (double)((long?)now["progressMs"] ?? 0) / dur)) : -1; } // Right before a skip: settle the request that's on. Call with the state lock held. void SettleBeforeSkip(bool byStreamer) { var st = LoadState(); var playing = st["playing"] as JObject; if (playing == null || (bool?)playing["settled"] == true) return; Settle(playing, PlayedShare(playing, OnNow(st)), byStreamer, LoadConfig()); SaveState(st); } // ───────────────────────── !voteskip, !wheresmysong, !srban ───────────────────────── const string G_VOTES = "spotifySR_votes"; // {uri, users: [user ids]}: skip votes for the song playing now // What's on now (a YouTube request, else what the tick last saw on Spotify), or null. JObject OnNow(JObject st) { var now = YouTubeNow(st) ?? CachedNow(); return now != null && !string.IsNullOrEmpty((string)now["uri"]) ? now : null; } // Is this the request playing now (by id, or by name when Spotify plays it under another id)? static bool IsSameSong(JObject a, JObject b) { return a != null && b != null && ((string)a["uri"] == (string)b["uri"] || (Norm((string)a["name"]) != "" && Norm((string)a["name"]) == Norm((string)b["name"]))); } // Viewers vote to skip the song playing now; the votes needed are a setting (0 = off). The requester of the song // can always skip their own with one vote. bool VoteSkip() { string user = UserName(), userId = Arg("userId"); long needed = (long)LoadConfig()["voteSkip"]; if (needed <= 0) { Tell("voteskip_off", null, user); return true; } var st = LoadState(); var now = OnNow(st); if (now == null || (bool?)now["isPlaying"] != true) { Tell("song_nothing", null); return true; } var votes = ParseObj(CPH.GetGlobalVar(G_VOTES, false)); if ((string)votes["uri"] != (string)now["uri"] || !(votes["users"] is JArray)) votes = new JObject { ["uri"] = now["uri"], ["users"] = new JArray() }; var users = (JArray)votes["users"]; var v = TrackVars(now, V("user", user, "needed", needed.ToString())); var playing = st["playing"] as JObject; bool own = playing != null && (string)playing["userId"] == userId && IsSameSong(playing, now); bool voted = users.Any(u => (string)u == userId); if (voted && !own) { Tell("voteskip_already", v, user); return true; } if (!voted) users.Add(userId); v["votes"] = users.Count.ToString(); if (own || users.Count >= needed) { CPH.SetGlobalVar(G_VOTES, "", false); Tell(own ? "voteskip_own" : "voteskip_done", v); int code; string key = SkipNow(false, out code); if (key != "skipped") Tell(key, V("code", code.ToString())); return true; } CPH.SetGlobalVar(G_VOTES, votes.ToString(Newtonsoft.Json.Formatting.None), false); Tell("voteskip_vote", v); return true; } // Where a viewer's next song is in line, and roughly how long until it plays: what's left of the song on now plus // everything ahead of it (3.5 min for a YouTube video whose length isn't known yet). bool WhereIsMySong() { string user = UserName(), userId = Arg("userId"); var st = LoadState(); var playing = st["playing"] as JObject; if (playing != null && (string)playing["userId"] == userId) { Tell("where_playing", TrackVars(playing), user); return true; } var line = new List(); if (st["pushed"] is JObject) line.Add((JObject)st["pushed"]); line.AddRange(((JArray)st["queue"]).Cast()); int i = line.FindIndex(e => (string)e["userId"] == userId); if (i < 0) { Tell("where_none", null, user); return true; } long ms = 0; var now = OnNow(st); if (now != null && (bool?)now["isPlaying"] == true) ms += Math.Max(0, ((long?)now["durationMs"] ?? 0) - ((long?)now["progressMs"] ?? 0)); for (int k = 0; k < i; k++) { long d = (long?)line[k]["durationMs"] ?? 0; ms += d > 0 ? d : 210000; } Tell("where", TrackVars(line[i], V("position", (i + 1).ToString(), "wait", HowLong(Math.Max(1, ms / 1000)))), user); return true; } // Mods: ban the song playing now from requests, and skip it (a request isn't refunded). bool BanNow() { if (!IsMod()) return true; var now = OnNow(LoadState()); if (now == null) { Tell("ban_nothing", null); return true; } Ban(now); Tell("banned", TrackVars(now)); int code; SkipNow(false, out code); return true; } void Ban(JObject t) { var c = LoadConfig(); var list = (JArray)c["blockSongs"]; string uri = (string)t["uri"]; if (!list.Any(x => (string)x["uri"] == uri)) list.Add(new JObject { ["uri"] = uri, ["name"] = t["name"], ["artists"] = t["artists"] is JArray ? Artists(t) : (string)t["artists"] ?? "" }); SaveConfig(c); Broadcast(ConfigMessage()); // the window and docks show the new ban } // ───────────────────────── the request playlist ───────────────────────── // Every Spotify request that starts playing goes into a playlist of the streamer's (private, made on first use). const string G_SCOPE = "spotifySR_scope"; // what the Spotify connection may do (from the token answer) const string G_PLAYLIST_ADDED = "spotifySR_playlistAdded"; // uris already in it, so a song goes in once // Connections made before 1.8 weren't allowed to change playlists: the window asks to reconnect. bool PlaylistAllowed() { return (CPH.GetGlobalVar(G_SCOPE, true) ?? "").Contains("playlist-modify-private"); } void AddToPlaylist(JObject e) { string uri = (string)e["uri"] ?? ""; if (!(bool)LoadConfig()["playlist"] || !uri.StartsWith("spotify:track:")) return; Task.Run(() => { try { JArray added; try { added = JArray.Parse(CPH.GetGlobalVar(G_PLAYLIST_ADDED, true) ?? "[]"); } catch { added = new JArray(); } if (added.Any(x => (string)x == uri)) return; string body = new JObject { ["uris"] = new JArray(uri) }.ToString(Newtonsoft.Json.Formatting.None); int code = 0; for (int attempt = 0; attempt < 2; attempt++) { string id = RequestPlaylist(); if (id == null) return; Api("POST", "/playlists/" + id + "/items", body, out code); if (code != 404) break; var c = LoadConfig(); // the playlist was deleted: make a new one c["playlistId"] = ""; c["playlistUrl"] = ""; SaveConfig(c); } if (code < 200 || code >= 300) return; added.Add(uri); while (added.Count > 2000) added.RemoveAt(0); CPH.SetGlobalVar(G_PLAYLIST_ADDED, added.ToString(Newtonsoft.Json.Formatting.None), true); } catch (Exception ex) { CPH.LogWarn("[SpotifySR] playlist: " + ex.Message); } }); } // The request playlist's id, made the first time. Null when Spotify says no (an older connection: reconnect). string RequestPlaylist() { var c = LoadConfig(); string id = (string)c["playlistId"]; if (!string.IsNullOrEmpty(id)) return id; int code; var res = Api("POST", "/me/playlists", new JObject { ["name"] = c["playlistName"], ["public"] = false, ["description"] = "Songs chat requested on stream, added by Spotify Song Requests." }.ToString(Newtonsoft.Json.Formatting.None), out code); id = res == null ? null : (string)res["id"]; if (id == null) { CPH.LogWarn("[SpotifySR] couldn't make the request playlist (Spotify said " + code + ")" + (code == 403 ? "; reconnect Spotify in the window to allow playlists" : "")); return null; } c = LoadConfig(); c["playlistId"] = id; c["playlistUrl"] = (string)res.SelectToken("external_urls.spotify") ?? ""; SaveConfig(c); CPH.LogInfo("[SpotifySR] made the request playlist \"" + c["playlistName"] + "\""); Broadcast(ConfigMessage()); return id; } // With requests waiting and none handed to Spotify yet, hand the next one over so a skip lands on it (not on // whatever Spotify had lined up next). Call with the state lock held. void HandNextForSkip(JObject cfg) { var st = LoadState(); var queue = (JArray)st["queue"]; if (st["pushed"] is JObject || queue.Count == 0 || IsYouTube((JObject)queue[0])) return; int code; var cp = Api("GET", NOW_PATH, null, out code); string cur = (string)cp?.SelectToken("item.uri") ?? ""; var next = (JObject)queue[0]; if (!HandToSpotify(cfg, next)) return; queue.Remove(next); next["pushedAt"] = Now(); var ahead = WaitListed((string)next["uri"]); next["ahead"] = (JToken)ahead ?? JValue.CreateNull(); next["seen"] = ahead != null; next["during"] = cur; st["pushed"] = next; SaveState(st); } // The songs ahead of a just-queued song, once Spotify lists it (it can take a moment); null if it never shows up. JArray WaitListed(string uri) { var ahead = AheadInSpotifyQueue(uri); for (int i = 0; i < 8 && ahead == null; i++) { Thread.Sleep(QUEUE_POLL_MS); ahead = AheadInSpotifyQueue(uri); } return ahead; } // "Something's stuck": forget the song handed to Spotify and what the tick remembers. The queue itself stays. // Returns the song it stopped waiting for ("" if none). string ResetStuck() { string forgot = ""; WithStateLock(15000, () => { var st = LoadState(); var p = st["pushed"] as JObject; if (p != null) forgot = Label(p); if (IsYouTube(st["playing"] as JObject)) Broadcast(new JObject { ["type"] = "yt-stop" }); Settle(st["playing"] as JObject, -1, false, LoadConfig()); st["pushed"] = null; st["playing"] = null; SaveState(st); }); CPH.SetGlobalVar(G_SEEN, "", false); CPH.SetGlobalVar(G_CATCHUP, "", false); CPH.SetGlobalVar(G_YT_NOW, "", false); PollSoon(); // look at Spotify again straight away CPH.LogInfo("[SpotifySR] reset" + (forgot != "" ? ", forgot " + forgot : "")); return forgot; } // The tick hasn't run for a while (its timer is off: see G_TICK_AT): switch the timer on. void KeepTickOn() { try { long now = Now(); if (now - CPH.GetGlobalVar(G_TICK_AT, false) < 20) return; long nudged = CPH.GetGlobalVar(G_TICK_NUDGE, false); if (now - nudged < 30) return; CPH.SetGlobalVar(G_TICK_NUDGE, now, false); try { CPH.EnableTimer(TICK_TIMER); } catch { } try { CPH.EnableTimerById(TICK_TIMER_ID); } catch { } if (nudged == 0) CPH.LogInfo("[SpotifySR] the tick wasn't running; switched the \"" + TICK_TIMER + "\" timer on"); } catch { } } // False when the tick is quiet even though its timer was just switched on (the window then says how to fix it). bool TickAlive() { try { long now = Now(); return now - CPH.GetGlobalVar(G_TICK_AT, false) < 20 || now - CPH.GetGlobalVar(G_TICK_NUDGE, false) < 25; } catch { return true; } } bool Clear() { if (!IsMod()) return true; var st = LoadState(); var cfg = LoadConfig(); var queue = (JArray)st["queue"]; int n = queue.Count; foreach (JObject e in queue.ToList()) Refund(e, cfg); st["queue"] = new JArray(); SaveState(st); Tell("cleared", V("songs", Plural(n, "song"), "count", n.ToString())); return true; } bool Toggle(bool on) { if (!IsMod()) return true; var st = LoadState(); st["enabled"] = on; SaveState(st); Tell(on ? "opened" : "closed_now", null); return true; } // ───────────────────────── queue → Spotify ───────────────────────── void Tick() { if (!Connected()) return; // The timer fires every 5 s, but Spotify is only asked once NextPollMs's wait is over (1 s slack for timer jitter). if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() < CPH.GetGlobalVar(G_NEXT_POLL, false) - 1000) return; var cfg = LoadConfig(); var st = LoadState(); bool dirty = false; int code; var cp = Api("GET", NOW_PATH, null, out code); if (IsYouTube(st["playing"] as JObject)) { // Spotify waits (paused) while a YouTube request plays; the player says when it's over. YouTubeTick(st, cfg, code == 200 ? cp : null); CPH.SetGlobalVar(G_NEXT_POLL, NowMs() + 10000, false); return; } if (code != 200 && code != 204) return; var nowMsg = new JObject { ["type"] = "now", ["now"] = NowFromPlayback(cp) }; RememberNow(nowMsg["now"]); Broadcast(nowMsg); var item = cp == null ? null : cp["item"] as JObject; string curUri = item == null ? null : (string)item["uri"]; // when Spotify plays its own country's version of a song, the id we queued is in linked_from string curLinked = item == null ? null : (string)item.SelectToken("linked_from.uri"); long progress = cp == null ? 0 : ((long?)cp["progress_ms"] ?? 0); long duration = item == null ? 0 : ((long?)item["duration_ms"] ?? 0); bool isPlaying = cp != null && ((bool?)cp["is_playing"] ?? false); // Spotify sometimes plays a request under another id with no link back to the one queued: then it's the same // name, first artist and length (within 3 s). string curName = item == null ? "" : Norm((string)item["name"]); string curArtist = item == null ? "" : Norm((string)item.SelectToken("artists[0].name")); Func isCurrent = (e) => e != null && curUri != null && ((string)e["uri"] == curUri || (curLinked != null && (string)e["uri"] == curLinked) || (curName != "" && Norm((string)e["name"]) == curName && Math.Abs(((long?)e["durationMs"] ?? -100000) - duration) <= 3000 && (curArtist == "" || Norm((string)e["artists"]).Contains(curArtist)))); // Did the song before this one play to its end? Or was it cut short (skipped in Spotify, or another song picked // there) with more than the hand-over window left? long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); bool endedByItself = false, cutShort = false; JObject seen = null; try { seen = JToken.Parse(CPH.GetGlobalVar(G_SEEN, false) ?? "null") as JObject; } catch { } if (seen != null && curUri != null && (string)seen["uri"] != curUri && ((bool?)seen["playing"] ?? false)) { long left = (long?)seen["left"] ?? long.MaxValue, since = nowMs - ((long?)seen["at"] ?? 0); endedByItself = left <= PUSH_BEFORE_END_MS + 10000 && since >= left - CROSSFADE_MS && since <= left + 15000; cutShort = !endedByItself && left > PUSH_BEFORE_END_MS + 10000 && since < left - CROSSFADE_MS; } // the same song started over (repeat, or played again from the queue) bool restarted = seen != null && curUri != null && (string)seen["uri"] == curUri && progress + 5000 < ((long?)seen["pos"] ?? 0); if (curUri != null) CPH.SetGlobalVar(G_SEEN, new JObject { ["uri"] = curUri, ["name"] = curName, ["left"] = Math.Max(0, duration - progress), ["at"] = nowMs, ["playing"] = isPlaying, ["pos"] = progress }.ToString(Newtonsoft.Json.Formatting.None), false); var pushed = st["pushed"] as JObject; var playing = st["playing"] as JObject; if (pushed != null && restarted && (string)pushed["during"] == curUri) pushed["during"] = ""; // the old play is over bool sameSongStill = pushed != null && (string)pushed["during"] == curUri; if (pushed != null && isCurrent(pushed) && !sameSongStill) { st["playing"] = pushed; st["pushed"] = null; pushed = null; dirty = true; var started = (JObject)st["playing"]; if ((bool)cfg["announce"]) Tell("now_playing", TrackVars(started, V("user", (string)started["user"]))); RememberPlayed(started); AddToPlaylist(started); } else if (playing != null && !isCurrent(playing)) { // The request is over: played out, or cut short in Spotify (skipped there, or another song picked). How far // it got: where the last look saw it, plus half the time since (it stopped somewhere in between). double played = -1; if (seen != null && ((string)seen["uri"] == (string)playing["uri"] || ((string)seen["name"] ?? "") != "" && (string)seen["name"] == Norm((string)playing["name"]))) { long dur = (long?)playing["durationMs"] ?? 0; long pos = ((long?)seen["pos"] ?? 0) + (((bool?)seen["playing"] ?? false) ? Math.Max(0, nowMs - ((long?)seen["at"] ?? nowMs)) / 2 : 0); if (dur > 0) played = Math.Min(1.0, (double)pos / dur); } Settle(playing, played, true, cfg); st["playing"] = null; dirty = true; } // The song that was playing when the request went to Spotify is over and something else is on. If the request is // definitely gone from Spotify's queue, Spotify dropped it (it couldn't play it): refund and move on now. if (pushed != null && curUri != null && !string.IsNullOrEmpty((string)pushed["during"]) && (string)pushed["during"] != curUri) { bool? waiting = QueueHas((string)pushed["uri"], ((bool?)pushed["seen"] ?? false)); if (waiting == true) pushed["seen"] = true; long retries = (long?)pushed["retries"] ?? 0; if (waiting == false && ((bool?)pushed["seen"] ?? true) == false && retries < 2) { // Spotify said OK but never listed it (it happens on some devices): try it again, first in line. CPH.LogWarn("[SpotifySR] Spotify never queued " + Label(pushed) + "; trying again"); foreach (var k in new[] { "ahead", "during", "late", "seen", "pushedAt" }) pushed.Remove(k); pushed["retries"] = retries + 1; ((JArray)st["queue"]).Insert(0, pushed); st["pushed"] = null; pushed = null; } else if (waiting == false) { CPH.LogWarn("[SpotifySR] " + Label(pushed) + " didn't play (skipped, or Spotify couldn't play it); moving on to the next request"); DroppedRequest(pushed, cfg); st["pushed"] = null; pushed = null; } else if (waiting == true) pushed["during"] = curUri; // still coming (behind the song Spotify had lined up) dirty = true; } // A handed-over song that never started (Spotify queue cleared by hand, etc.): stop waiting for it. if (pushed != null && Now() - (long)pushed["pushedAt"] > 20 * 60) { if (!InSpotifyQueue((string)pushed["uri"])) { DroppedRequest(pushed, cfg); st["pushed"] = null; pushed = null; } else pushed["pushedAt"] = Now(); dirty = true; } var queue = (JArray)st["queue"]; // YouTube requests never go into Spotify's queue; they're started below. bool spotifyNext = queue.Count > 0 && !IsYouTube((JObject)queue[0]); // A request missed its turn: the last song ended and Spotify started one of its own (the hand-over came too // late, or failed on a network blip). Put the request first and skip Spotify's song while it's seconds in. var nowRequest = st["playing"] as JObject; bool requestPlaying = isCurrent(nowRequest); // a request just started: nothing missed string pendingCatchUp = CPH.GetGlobalVar(G_CATCHUP, false) ?? ""; bool catchUp = (endedByItself || (pendingCatchUp != "" && pendingCatchUp == curUri)) && !requestPlaying && isPlaying && progress < CATCH_UP_MS && (pushed != null || spotifyNext); bool skipped = false; if (catchUp) { if (pushed == null) { var next = (JObject)queue[0]; if (HandToSpotify(cfg, next)) { queue.Remove(next); next["pushedAt"] = Now(); next["during"] = curUri; st["pushed"] = next; pushed = next; dirty = true; } } // Never a song you queued in Spotify yourself (it was ahead of the request when it was handed over). When // the hand-over came late, the first song ahead was probably the one Spotify had already lined up: not yours. var ahead = pushed == null ? null : pushed["ahead"] as JArray; bool late = pushed != null && ((bool?)pushed["late"] ?? false); bool yourOwn = ahead != null && ahead.Skip(late ? 1 : 0).Any(u => (string)u == curUri); if (pushed != null && !yourOwn && FirstInSpotifyQueue((string)pushed["uri"]) && SkipToNext(cfg)) { skipped = true; CPH.LogInfo("[SpotifySR] a request missed its turn; skipped Spotify's " + ((string)item?["name"] ?? "song") + " to play " + Label(pushed)); } } // not skipped yet (Spotify's queue list can lag a moment): try again next tick while the song is still young string pendingNow = catchUp && !skipped && pushed != null ? curUri : ""; if (pendingNow != pendingCatchUp) CPH.SetGlobalVar(G_CATCHUP, pendingNow, false); // Skipped in Spotify itself (or another song picked there) while requests wait and none is handed over: hand the // next one over now, so it plays right after this song (and skipping again in Spotify lands on it). The song // that's on isn't skipped: Spotify can't tell us whether you queued it yourself. if (cutShort && !catchUp && pushed == null && spotifyNext && isPlaying && !requestPlaying) { var next = (JObject)queue[0]; if (HandToSpotify(cfg, next)) { queue.Remove(next); next["pushedAt"] = Now(); next["during"] = curUri; var listed = WaitListed((string)next["uri"]); next["ahead"] = (JToken)listed ?? JValue.CreateNull(); next["seen"] = listed != null; st["pushed"] = next; pushed = next; dirty = true; CPH.LogInfo("[SpotifySR] a song was skipped or picked in Spotify; " + Label(next) + " goes next, after " + ((string)item?["name"] ?? "this song")); } } if (pushed == null && spotifyNext && isPlaying && duration > 0 && duration - progress <= PUSH_BEFORE_END_MS) { var next = (JObject)queue[0]; if (HandToSpotify(cfg, next)) { queue.Remove(next); next["pushedAt"] = Now(); // songs already waiting in Spotify's queue ahead of it (queued there by hand; see the catch-up above) var listed = AheadInSpotifyQueue((string)next["uri"]); next["ahead"] = (JToken)listed ?? JValue.CreateNull(); next["seen"] = listed != null; // Spotify actually lists it (see "never queued" above) next["during"] = curUri; next["late"] = duration - progress < 20000; st["pushed"] = next; dirty = true; } } // A YouTube request is next and no Spotify request is on its way first. Spotify has nothing on: it starts now. // A song is playing: Spotify is paused right at its end. Spotify already moved on to a song of its own (the end // was missed): that song goes back to its start and waits. Spotify paused: it waits too. if (queue.Count > 0 && IsYouTube((JObject)queue[0]) && !(st["pushed"] is JObject) && YtPlayer() != null) { if (cp == null) { StartYouTube(st, cfg, false, false); dirty = false; } else if (isPlaying && endedByItself && !requestPlaying && progress < CATCH_UP_MS) { StartYouTube(st, cfg, true, false); dirty = false; } else if (isPlaying && duration > 0 && duration - progress <= 12000) ScheduleYouTube(duration - progress - (NowMs() - nowMs)); } bool busy = st["pushed"] is JObject || queue.Count > 0 || pendingNow != ""; CPH.SetGlobalVar(G_NEXT_POLL, nowMs + NextPollMs(item != null && isPlaying, duration - progress, busy, nowMs), false); if (dirty) SaveState(st); } // How long until the tick asks Spotify again. Every tick (5 s) while requests wait or one is on its way to Spotify: // hand-overs need it. While a song plays, every 10 s, or just after it should end so the overlay changes on time. // With nothing playing, every 10 s, then every 30 s after 10 minutes. Asking every 5 s around the clock (17,000 // calls a day, 46,000 with the window open) used up Spotify's daily quota. long NextPollMs(bool playing, long leftMs, bool busy, long nowMs) { if (playing) { CPH.SetGlobalVar(G_IDLE_SINCE, 0L, false); return busy ? 0 : Math.Max(0, Math.Min(10000, leftMs + 1500)); } long idleSince = CPH.GetGlobalVar(G_IDLE_SINCE, false); if (idleSince == 0) CPH.SetGlobalVar(G_IDLE_SINCE, idleSince = nowMs, false); return busy || nowMs - idleSince < 10 * 60000 ? 10000 : 30000; } // Something just changed (a request came in, a skip): the next tick asks Spotify whatever NextPollMs said. void PollSoon() { CPH.SetGlobalVar(G_NEXT_POLL, 0L, false); } // A request that went to Spotify but never played: give the points back and say so. void DroppedRequest(JObject e, JObject cfg) { Refund(e, cfg); bool paidFor = ((long?)e["paid"] ?? 0) > 0 || ((string)e["redemptionId"] ?? "") != ""; Tell("dropped", TrackVars(e, V("user", (string)e["user"], "refunded", paidFor ? ", so it was refunded." : "."))); } // Adds a request to Spotify's own queue (on the chosen device, or wherever Spotify is playing). bool HandToSpotify(JObject cfg, JObject song) { int qc; string uriParam = "?uri=" + Uri.EscapeDataString((string)song["uri"]); Api("POST", "/me/player/queue" + uriParam + DeviceQuery(cfg, "&"), "", out qc); if (qc == 404 && DeviceId(cfg) != "") Api("POST", "/me/player/queue" + uriParam, "", out qc); return qc >= 200 && qc < 300; } // Is this song the very next one in Spotify's queue? (Songs you queued in Spotify by hand come first.) bool FirstInSpotifyQueue(string uri) { int code; var q = Api("GET", "/me/player/queue", null, out code); var items = q == null ? null : q["queue"] as JArray; return items != null && items.Count > 0 && (string)items[0]["uri"] == uri; } // The songs ahead of this one in Spotify's queue; null if Spotify didn't answer or doesn't list it. JArray AheadInSpotifyQueue(string uri) { int code; var q = Api("GET", "/me/player/queue", null, out code); var items = q == null ? null : q["queue"] as JArray; if (items == null) return null; var ahead = new JArray(); foreach (var i in items) { if ((string)i["uri"] == uri || (string)i.SelectToken("linked_from.uri") == uri) return ahead; ahead.Add((string)i["uri"]); } return null; } bool SkipToNext(JObject cfg) { int code; Api("POST", "/me/player/next" + DeviceQuery(cfg, "?"), "", out code); if (code == 404 && DeviceId(cfg) != "") Api("POST", "/me/player/next", "", out code); PollSoon(); return code >= 200 && code < 300; } // Is this song in Spotify's queue? null when Spotify didn't answer, or the list is full (Spotify lists 20) and the // song was never seen in it. Once Spotify has listed a queued song it only moves up, so if it's gone it's gone. bool? QueueHas(string uri, bool listedBefore = false) { int code; var q = Api("GET", "/me/player/queue", null, out code); var items = q == null ? null : q["queue"] as JArray; if (items == null) return null; if (items.Any(i => (string)i["uri"] == uri || (string)i.SelectToken("linked_from.uri") == uri)) return true; return items.Count >= 20 && !listedBefore ? (bool?)null : false; } // Song names compared loosely: lower case, letters and digits only. static string Norm(string s) { if (string.IsNullOrEmpty(s)) return ""; var b = new System.Text.StringBuilder(); foreach (char ch in s.ToLowerInvariant()) if (char.IsLetterOrDigit(ch)) b.Append(ch); return b.ToString(); } bool InSpotifyQueue(string uri) { int code; var q = Api("GET", "/me/player/queue", null, out code); var items = q == null ? null : q["queue"] as JArray; return items != null && items.Any(i => (string)i["uri"] == uri); } // ───────────────────────── track lookup ───────────────────────── // Search results don't say whether a song can play in the streamer's country, and the top hit can be a version // that can't: Spotify then skips it without a word, or plays its own country's version under another id, and the // request never "starts". Look the top hits (the same song) up again for this account's country and take the // first that plays here; that lookup returns the country's own version when Spotify swaps one in. JObject PlayableHere(JArray hits) { var top = (JObject)hits[0]; Func key = (t) => (((string)t["name"] ?? "") + "|" + ((string)t.SelectToken("artists[0].name") ?? "")).ToLowerInvariant(); JObject unplayable = null; foreach (JObject hit in hits.Where(h => key((JObject)h) == key(top)).Take(3)) { int code; var local = Api("GET", "/tracks/" + (string)hit["id"] + "?market=from_token", null, out code); if (local == null) return unplayable == null ? top : hit; // couldn't check: go with the search var playable = local["is_playable"]; if (playable != null && playable.Type == JTokenType.Boolean && !(bool)playable) { if (unplayable == null) unplayable = local; continue; } return local; } return unplayable ?? top; // nothing plays here: the caller tells the viewer } // err: the message key when nothing playable was found (v gets its placeholders). JObject Resolve(string input, Dictionary v, out string err) { err = null; var m = Regex.Match(input, @"(?:open\.spotify\.com/(?:intl-[a-z-]+/)?track/|spotify:track:)([A-Za-z0-9]{22})", RegexOptions.IgnoreCase); if (m.Success) { var t = Api("GET", "/tracks/" + m.Groups[1].Value + "?market=from_token", null); if (t == null) err = "spotify_link_not_found"; return t; } if (Regex.IsMatch(input, @"open\.spotify\.com/|spotify:", RegexOptions.IgnoreCase)) { err = "wrong_link"; return null; } string ytId = YouTubeId(input); if (ytId != null) return ResolveYouTube(ytId, v, out err); if (Regex.IsMatch(input, @"(youtube\.com|youtu\.be)/", RegexOptions.IgnoreCase)) { err = "bad_youtube_link"; return null; } if (Regex.IsMatch(input, @"^\s*https?://", RegexOptions.IgnoreCase)) { err = "bad_link"; return null; } // No market=from_token here: on search it needs the user-read-private scope (403 otherwise). var res = Api("GET", "/search?type=track&limit=5&q=" + Uri.EscapeDataString(input), null); var items = res == null ? null : res.SelectToken("tracks.items") as JArray; if (items == null || items.Count == 0) { v["query"] = Trunc(input, 60); err = "no_results"; return null; } return PlayableHere(items); } // ───────────────────────── YouTube requests ───────────────────────── static bool IsYouTube(JObject e) { return e != null && (string)e["source"] == "youtube"; } // The video id in a YouTube link (watch, youtu.be, shorts, embed, live, music.youtube.com), or null. static string YouTubeId(string text) { var m = Regex.Match(text ?? "", @"(?:youtube\.com/(?:watch\?(?:[^\s#]*?&)?v=|shorts/|embed/|live/|v/)|youtu\.be/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])", RegexOptions.IgnoreCase); return m.Success ? m.Groups[1].Value : null; } class YtInfo { public string Id, Title, Author; public long Seconds = -1; // -1: unknown (YouTube asked for a sign-in check instead of the page) public bool? Embeddable; // null: unknown; the player finds out when it loads the video public bool Live, Missing; } // Title and channel from YouTube's oEmbed; length, live and "may play outside YouTube" from the watch page when // YouTube serves it (it sometimes answers with a bot check instead, then the player checks when it loads it). static YtInfo YouTubeInfo(string id) { var info = new YtInfo { Id = id }; int code; string text = Http("GET", "https://www.youtube.com/oembed?format=json&url=" + Uri.EscapeDataString("https://www.youtube.com/watch?v=" + id), null, null, null, out code); if (code == 200) { try { var o = JObject.Parse(text); info.Title = (string)o["title"]; info.Author = (string)o["author_name"]; } catch { } } else if (code == 401 || code == 403) info.Embeddable = false; else if (code == 400 || code == 404) info.Missing = true; string page = FetchPage("https://www.youtube.com/watch?v=" + id + "&hl=en", out code); if (code == 200 && page != null) { var len = Regex.Match(page, "\"lengthSeconds\":\"(\\d+)\""); if (len.Success) info.Seconds = long.Parse(len.Groups[1].Value); var emb = Regex.Match(page, "\"playableInEmbed\":(true|false)"); if (emb.Success) info.Embeddable = emb.Groups[1].Value == "true"; info.Live = Regex.IsMatch(page, "\"isLive\":true|\"isUpcoming\":true"); if (info.Title == null) { var t = Regex.Match(page, " v, out string err) { err = null; var cfg = LoadConfig(); var info = YouTubeInfo(id); if (info.Missing) { err = "youtube_missing"; return null; } string title = info.Title ?? "that video"; v["song"] = Trunc(title, 60); bool usePlayer = (string)cfg["youtube"] != "spotify"; if (usePlayer && YtPlayer() != null) { if (info.Live) { err = "youtube_live"; return null; } if (info.Embeddable == false) { err = "youtube_no_embed"; return null; } return YouTubeTrack(info); } var t = SpotifyMatch(info); if (t != null) return t; err = usePlayer ? "youtube_no_player" : "youtube_not_on_spotify"; return null; } static JObject YouTubeTrack(YtInfo info) { string artist, song; SplitTitle(info.Title, info.Author, out artist, out song); if (song == "") song = info.Title ?? "YouTube video"; return new JObject { ["uri"] = "youtube:" + info.Id, ["source"] = "youtube", ["videoId"] = info.Id, ["name"] = song, ["artists"] = new JArray(new JObject { ["name"] = artist != "" ? artist : "YouTube" }), ["duration_ms"] = info.Seconds > 0 ? info.Seconds * 1000 : 0, // 16:9 without black bars; the 300 px stack overlay crops it square ["images"] = new JArray(new JObject { ["url"] = "https://i.ytimg.com/vi/" + info.Id + "/mqdefault.jpg" }), ["url"] = "https://youtu.be/" + info.Id }; } // The Spotify track that is clearly the video's song: same name (ignoring "(Official Video)", "feat." and // "- Remastered" bits) and an artist that matches. Null when no result is that sure. JObject SpotifyMatch(YtInfo info) { if (string.IsNullOrEmpty(info.Title)) return null; string artist, song; SplitTitle(info.Title, info.Author, out artist, out song); Func bare = s => Norm(Regex.Replace(Regex.Replace(s ?? "", @"\s*[\(\[][^\)\]]*[\)\]]", " "), @"\s+(?:[-–—]\s+.*|(?:ft\.?|feat\.?|featuring)\s.*)$", "", RegexOptions.IgnoreCase)); string want = bare(song); var wantArtists = new[] { Norm(artist), Norm(Regex.Replace(info.Author ?? "", @"(\s*-\s*Topic|VEVO)$", "", RegexOptions.IgnoreCase)) }.Where(a => a.Length >= 2).ToList(); if (want == "" || wantArtists.Count == 0) return null; var res = Api("GET", "/search?type=track&limit=10&q=" + Uri.EscapeDataString(artist + " " + Regex.Replace(song, @"\s*[\(\[][^\)\]]*[\)\]]", " ")), null); var items = res == null ? null : res.SelectToken("tracks.items") as JArray; if (items == null) return null; foreach (JObject t in items) { if (bare((string)t["name"]) != want) continue; var names = ((t["artists"] as JArray) ?? new JArray()).Select(a => Norm((string)a["name"])).Where(a => a.Length >= 2); if (!names.Any(a => wantArtists.Any(w => w.Contains(a) || a.Contains(w)))) continue; return PlayableHere(new JArray(t)); } return null; } // ───────────────────────── playing YouTube requests ───────────────────────── // A YouTube request starts when its turn comes: at the end of the Spotify song that's playing (Spotify is paused for // it and picks up again afterwards), right after another request, on a skip, or straight away when Spotify has // nothing on at all. A paused Spotify holds it, like it holds Spotify requests. static long NowMs() { return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } // The YouTube player that's open and in charge, or null. JObject YtPlayer() { var p = ParseObj(CPH.GetGlobalVar(G_YT_PLAYER, false)); return NowMs() - ((long?)p["at"] ?? 0) < YT_PLAYER_GONE_MS ? p : null; } // Plays the YouTube request at the front of the queue. pauseSpotify: Spotify may be playing, pause it (and resume // it afterwards); a song of Spotify's that only just started goes back to its start for then. Call with the state // lock held. False when nothing started (no YouTube request first, or no player). bool StartYouTube(JObject st, JObject cfg, bool pauseSpotify, bool resumeAfter) { var queue = (JArray)st["queue"]; var player = YtPlayer(); if (queue.Count == 0 || !IsYouTube((JObject)queue[0]) || player == null) return false; var next = (JObject)queue[0]; bool resume = resumeAfter; if (pauseSpotify) { int code; var cp = Api("GET", NOW_PATH, null, out code); if (cp != null && ((bool?)cp["is_playing"] ?? false)) { long progress = (long?)cp["progress_ms"] ?? 0; Api("PUT", "/me/player/pause" + DeviceQuery(cfg, "?"), "", out code); if (code == 404 && DeviceId(cfg) != "") Api("PUT", "/me/player/pause", "", out code); if (progress > 1500 && progress < CATCH_UP_MS) Api("PUT", "/me/player/seek?position_ms=0" + DeviceQuery(cfg, "&"), "", out code); resume = true; } } queue.RemoveAt(0); next["startedAt"] = NowMs(); next["resume"] = resume; st["playing"] = next; CPH.SetGlobalVar(G_YT_START, 0L, false); CPH.SetGlobalVar(G_YT_NOW, new JObject { ["videoId"] = next["videoId"], ["progressMs"] = 0, ["durationMs"] = next["durationMs"], ["isPlaying"] = true, ["at"] = NowMs() }.ToString(Newtonsoft.Json.Formatting.None), false); SaveState(st); Broadcast(new JObject { ["type"] = "yt-play", ["player"] = player["id"], ["videoId"] = next["videoId"], ["seq"] = next["seq"], ["startMs"] = 0 }); BroadcastNow(st); if ((bool)cfg["announce"]) Tell("now_playing", TrackVars(next, V("user", (string)next["user"]))); RememberPlayed(next); CPH.LogInfo("[SpotifySR] YouTube: playing " + Label(next) + (resume ? ", Spotify paused" : "")); return true; } // The YouTube request is over: played to the end, failed (refunded, with why in chat), skipped, or its player // went away. Next comes another YouTube request straight away; otherwise Spotify picks up where it was paused, // with a waiting Spotify request handed over first so it plays next. Call with the state lock held. void FinishYouTube(JObject st, JObject cfg, string failed) { var cur = st["playing"] as JObject; if (!IsYouTube(cur)) return; if (failed != null) cur["settled"] = true; // refunded below else Settle(cur, -1, false, cfg); // played out (or already settled by a skip) bool resume = (bool?)cur["resume"] ?? false; st["playing"] = null; CPH.SetGlobalVar(G_YT_NOW, "", false); Broadcast(new JObject { ["type"] = "yt-stop" }); if (failed != null) { Refund(cur, cfg); Tell("yt_failed", TrackVars(cur, V("reason", failed, "refund", RefundNote(cur, cfg))), (string)cur["user"]); } SaveState(st); if (StartYouTube(st, cfg, false, resume)) return; if (resume) { HandNextForSkip(cfg); int code; Api("PUT", "/me/player/play" + DeviceQuery(cfg, "?"), "", out code); if (code == 404 && DeviceId(cfg) != "") Api("PUT", "/me/player/play", "", out code); } RememberNow(null); Broadcast(new JObject { ["type"] = "now", ["now"] = null }); PollSoon(); } // Pause Spotify and start the YouTube request right at the end of the song that's playing (leftMs from now). void ScheduleYouTube(long leftMs) { long fireAt = NowMs() + Math.Max(0, leftMs - 700); long set = CPH.GetGlobalVar(G_YT_START, false); if (set > 0 && Math.Abs(set - fireAt) < 3000) return; // already set for this song's end CPH.SetGlobalVar(G_YT_START, fireAt, false); Task.Run(() => { Thread.Sleep((int)Math.Max(0, fireAt - NowMs())); if (CPH.GetGlobalVar(G_YT_START, false) != fireAt) return; // replaced, or something started already WithStateLock(15000, () => { var st = LoadState(); if (IsYouTube(st["playing"] as JObject) || st["pushed"] is JObject) return; StartYouTube(st, LoadConfig(), true, false); }); }); } // What every page shows while a YouTube request plays, from the player's last report moved on by the time since. JObject YouTubeNow(JObject st) { var cur = st["playing"] as JObject; if (!IsYouTube(cur)) return null; var yt = ParseObj(CPH.GetGlobalVar(G_YT_NOW, false)); bool playing = (bool?)yt["isPlaying"] ?? true; long dur = (long?)yt["durationMs"] ?? 0; if (dur <= 0) dur = (long?)cur["durationMs"] ?? 0; long pos = ((long?)yt["progressMs"] ?? 0) + (playing ? Math.Max(0, NowMs() - ((long?)yt["at"] ?? NowMs())) : 0); if (dur > 0) pos = Math.Min(pos, dur); return new JObject { ["uri"] = cur["uri"], ["name"] = cur["name"], ["artists"] = cur["artists"], ["image"] = cur["image"], ["progressMs"] = pos, ["durationMs"] = dur, ["isPlaying"] = playing, ["source"] = "youtube", ["url"] = cur["url"] }; } void BroadcastNow(JObject st) { var now = YouTubeNow(st); if (now == null) return; RememberNow(now); Broadcast(new JObject { ["type"] = "now", ["now"] = now }); } // The tick while a YouTube request plays: it only watches for the player going away, and for Spotify started again // by hand (the streamer took over: the video stops, nobody is refunded). void YouTubeTick(JObject st, JObject cfg, JObject cp) { var cur = (JObject)st["playing"]; bool spotifyPlaying = cp != null && ((bool?)cp["is_playing"] ?? false); if (YtPlayer() == null) { CPH.LogWarn("[SpotifySR] YouTube: the player stopped checking in during " + Label(cur) + "; moving on"); FinishYouTube(st, cfg, null); return; } if (spotifyPlaying && NowMs() - ((long?)cur["startedAt"] ?? 0) > 8000) { CPH.LogInfo("[SpotifySR] YouTube: Spotify was started by hand; stopping " + Label(cur)); Settle(cur, PlayedShare(cur, YouTubeNow(st)), true, cfg); cur["resume"] = false; FinishYouTube(st, cfg, null); return; } BroadcastNow(st); } static string YouTubeError(long code) { // 150 is also what a player gets when YouTube wants it to "sign in to confirm you're not a bot" if (code == 101 || code == 150 || code == 152 || code == 153) return "can't be played on stream (YouTube refused it outside youtube.com)"; if (code == 100) return "isn't available (private, deleted or blocked here)"; if (code == 2) return "has a broken link"; return "couldn't be played (YouTube error " + code + ")"; } static string CleanTitle(string t) { t = Regex.Replace(t, @"[\(\[][^\)\]]*(official|video|audio|lyric|visuali[sz]er|\bhd\b|4k|\bmv\b|m/v)[^\)\]]*[\)\]]", " ", RegexOptions.IgnoreCase); t = Regex.Replace(t, @"\b(official (music )?video|official audio|lyric video|lyrics)\b", " ", RegexOptions.IgnoreCase); t = t.Split('|')[0]; t = Regex.Replace(t, @"\s+", " ").Trim(' ', '-', '–'); return t; } // ───────────────────────── Spotify API + auth ───────────────────────── JObject Api(string method, string path, string body) { int code; return Api(method, path, body, out code); } JObject Api(string method, string path, string body, out int status) { if (BackoffLeft() > 0) { status = 429; // still waiting out Spotify's last 429: don't spend quota on more of them return null; } string token = AccessToken(); if (token == null) { status = 401; return null; } string url = "https://api.spotify.com/v1" + path; string retryAfter; string text = Http(method, url, body, "Bearer " + token, "application/json", out status, out retryAfter); if (status == 401) { token = Refresh(true); if (token == null) return null; text = Http(method, url, body, "Bearer " + token, "application/json", out status, out retryAfter); } if (status == 429) { BackOff(retryAfter, text); return null; } if (status >= 400 || status == 0) CPH.LogWarn("[SpotifySR] " + method + " " + path + " -> " + status + " " + Trunc(text, 300)); if (status >= 200 && status < 300 && !string.IsNullOrWhiteSpace(text)) { try { return JObject.Parse(text); } catch { } } return null; } // Spotify answered 429: no calls until its Retry-After (seconds) has passed. A used-up quota // (reason QUOTA_EXCEEDED) asks for hours; without the header, wait 10 minutes for that and 30 s otherwise. void BackOff(string retryAfter, string body) { bool quota = body != null && body.IndexOf("QUOTA_EXCEEDED", StringComparison.Ordinal) >= 0; long secs; if (!long.TryParse((retryAfter ?? "").Trim(), out secs) || secs <= 0) secs = quota ? 600 : 30; secs = Math.Min(secs, 24 * 3600); long until = Now() + secs; if (until <= GlobalLong(G_BACKOFF)) return; CPH.SetGlobalVar(G_BACKOFF, until.ToString(), true); CPH.LogWarn("[SpotifySR] " + (quota ? "Spotify says this developer account used up its API quota" : "Spotify says too many requests") + "; no Spotify calls for " + HowLong(secs) + " (until " + DateTimeOffset.FromUnixTimeSeconds(until).ToLocalTime().ToString("HH:mm") + ")"); try { Broadcast(StateMessage(LoadState())); } catch { } // the window and docks show it } // Seconds left before Spotify may be asked again (0 = go ahead). long BackoffLeft() { return Math.Max(0, GlobalLong(G_BACKOFF) - Now()); } static string HowLong(long secs) { if (secs < 90) return Math.Max(1, secs) + " s"; long min = (secs + 59) / 60; return min < 60 ? min + " min" : (min / 60) + " h" + (min % 60 > 0 ? " " + (min % 60) + " min" : ""); } bool Connected() { return !string.IsNullOrEmpty(CPH.GetGlobalVar(G_REFRESH, true)) && (string)LoadConfig()["clientId"] != ""; } string AccessToken() { string t = CPH.GetGlobalVar(G_ACCESS, true); if (!string.IsNullOrEmpty(t) && Now() < GlobalLong(G_EXPIRES) - 60) return t; return Refresh(false); } // Spotify may rotate the refresh token, so two actions refreshing at once could lock each // other out. A named mutex serializes it across the separately compiled actions. string Refresh(bool force) { using (var mutex = new Mutex(false, "StreamerBot_SpotifySR_Refresh")) { bool owned = false; try { try { owned = mutex.WaitOne(15000); } catch (AbandonedMutexException) { owned = true; } string current = CPH.GetGlobalVar(G_ACCESS, true); if (!force && !string.IsNullOrEmpty(current) && Now() < GlobalLong(G_EXPIRES) - 60) return current; string rt = CPH.GetGlobalVar(G_REFRESH, true); string clientId = (string)LoadConfig()["clientId"]; if (string.IsNullOrEmpty(rt) || clientId == "") return null; int code; string text = Http("POST", "https://accounts.spotify.com/api/token", "grant_type=refresh_token&refresh_token=" + Uri.EscapeDataString(rt) + "&client_id=" + Uri.EscapeDataString(clientId), null, "application/x-www-form-urlencoded", out code); if (code != 200) { CPH.LogWarn("[SpotifySR] token refresh failed: " + code + " " + Trunc(text, 300)); if (code == 400) CPH.SetGlobalVar(G_REFRESH, "", true); // revoked: needs reconnecting return null; } return SaveTokens(JObject.Parse(text)); } finally { if (owned) mutex.ReleaseMutex(); } } } string SaveTokens(JObject j) { string at = (string)j["access_token"]; CPH.SetGlobalVar(G_ACCESS, at, true); CPH.SetGlobalVar(G_EXPIRES, (Now() + ((long?)j["expires_in"] ?? 3600)).ToString(), true); string rt = (string)j["refresh_token"]; if (!string.IsNullOrEmpty(rt)) CPH.SetGlobalVar(G_REFRESH, rt, true); if (j["scope"] != null) CPH.SetGlobalVar(G_SCOPE, (string)j["scope"], true); return at; } static string Http(string method, string url, string body, string auth, string contentType, out int status) { string retryAfter; return Http(method, url, body, auth, contentType, out status, out retryAfter); } static string Http(string method, string url, string body, string auth, string contentType, out int status, out string retryAfter) { retryAfter = null; try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; } catch { } var req = (HttpWebRequest)WebRequest.Create(url); req.Method = method; req.Timeout = 10000; req.ReadWriteTimeout = 10000; req.UserAgent = "SpotifySR-StreamerBot/" + VERSION; if (auth != null) req.Headers["Authorization"] = auth; try { if (body != null) { byte[] bytes = Encoding.UTF8.GetBytes(body); req.ContentType = contentType; req.ContentLength = bytes.Length; if (bytes.Length > 0) using (var s = req.GetRequestStream()) s.Write(bytes, 0, bytes.Length); } using (var resp = (HttpWebResponse)req.GetResponse()) { status = (int)resp.StatusCode; return ReadAll(resp); } } catch (WebException ex) { var r = ex.Response as HttpWebResponse; if (r == null) { status = 0; return ex.Message; } using (r) { status = (int)r.StatusCode; retryAfter = r.Headers["Retry-After"]; return ReadAll(r); } } } static string ReadAll(HttpWebResponse resp) { using (var s = resp.GetResponseStream()) { if (s == null) return ""; using (var reader = new StreamReader(s, Encoding.UTF8)) return reader.ReadToEnd(); } } // ───────────────────────── money back ───────────────────────── void Refund(JObject e, JObject cfg) { long paid = (long?)e["paid"] ?? 0; string currencyVar = (string)cfg["currencyVar"]; if (paid > 0 && currencyVar != "") { string userId = (string)e["userId"]; CPH.SetTwitchUserVarById(userId, currencyVar, UserVarLong(userId, currencyVar) + paid, true); } RefundRedemption((string)e["rewardId"], (string)e["redemptionId"]); } string RefundNote(JObject e, JObject cfg) { long paid = (long?)e["paid"] ?? 0; if (paid > 0) return " and refunded " + paid + " " + cfg["currencyName"] + "."; if (!string.IsNullOrEmpty((string)e["redemptionId"])) return " and refunded your channel points."; return "."; } // Channel points can only be refunded for rewards Streamer.bot created itself. void RefundRedemption(string rewardId, string redemptionId) { if (string.IsNullOrEmpty(rewardId) || string.IsNullOrEmpty(redemptionId)) return; try { CPH.TwitchRedemptionCancel(rewardId, redemptionId); } catch (Exception ex) { CPH.LogWarn("[SpotifySR] refund failed: " + ex.Message); } } void FulfillRedemption(JObject e) { string rewardId = (string)e["rewardId"], redemptionId = (string)e["redemptionId"]; if (string.IsNullOrEmpty(rewardId) || string.IsNullOrEmpty(redemptionId)) return; try { CPH.TwitchRedemptionFulfill(rewardId, redemptionId); } catch { } } // ───────────────────────── config + state ───────────────────────── JObject LoadConfig() { JObject c = ParseObj(CPH.GetGlobalVar(G_CONFIG, true)); Def(c, "clientId", ""); Def(c, "deviceId", ""); Def(c, "deviceName", ""); Def(c, "currencyVar", DEFAULT_CURRENCY_VAR); Def(c, "currencyName", DEFAULT_CURRENCY_NAME); Def(c, "cost", DEFAULT_COST); Def(c, "cooldown", (long)DEFAULT_COOLDOWN); Def(c, "freeUsers", DEFAULT_FREE_USERS); Def(c, "maxMinutes", 8L); Def(c, "maxPerUser", 3L); Def(c, "announce", true); Def(c, "youtube", "player"); // "player": YouTube links play on the YouTube player; "spotify": only their exact song on Spotify Def(c, "replyMode", "mention"); // "mention": "@user …"; "reply": a reply under their message Def(c, "access", "everyone"); // who may request: everyone, subs (subscribers, VIPs, mods), mods Def(c, "costSub", -1L); // -1: the same as everyone Def(c, "costVip", -1L); Def(c, "maxPerUserSub", -1L); // songs waiting at once for subscribers and VIPs; -1: the same Def(c, "blockExplicit", false); Def(c, "blockArtists", ""); // names, one per line or comma separated if (!(c["blockSongs"] is JArray)) c["blockSongs"] = new JArray(); // [{uri, name, artists}] Def(c, "noRepeatMinutes", 0L); // a request that played can't be requested again for this long; 0: off Def(c, "voteSkip", 0L); // !voteskip votes that skip a song; 0: off Def(c, "refundSkipPercent", 50L); // you or a mod skip a request before it played this much of itself: refunded; 0: never Def(c, "playlist", false); // add every Spotify request that plays to a playlist Def(c, "playlistName", "Stream requests"); Def(c, "playlistId", ""); Def(c, "playlistUrl", ""); if (!(c["messages"] is JObject)) c["messages"] = new JObject(); return c; } void SaveConfig(JObject c) { CPH.SetGlobalVar(G_CONFIG, c.ToString(Newtonsoft.Json.Formatting.None), true); } JObject LoadState() { JObject s = ParseObj(CPH.GetGlobalVar(G_STATE, true)); Def(s, "enabled", true); Def(s, "seq", 0L); if (!(s["queue"] is JArray)) s["queue"] = new JArray(); if (s["pushed"] == null) s["pushed"] = null; if (s["playing"] == null) s["playing"] = null; return s; } void SaveState(JObject s) { CPH.SetGlobalVar(G_STATE, s.ToString(Newtonsoft.Json.Formatting.None), true); Broadcast(StateMessage(s)); } static JObject ParseObj(string raw) { if (string.IsNullOrWhiteSpace(raw)) return new JObject(); try { return JObject.Parse(raw); } catch { return new JObject(); } } static void Def(JObject o, string key, JToken value) { if (o[key] == null || o[key].Type == JTokenType.Null) o[key] = value; } // ───────────────────────── small helpers ───────────────────────── string Arg(string key) { object v; return args.TryGetValue(key, out v) && v != null ? v.ToString() : ""; } string UserName() { string u = Arg("user"); return u != "" ? u : Arg("userName"); } // 1 viewer, 2 VIP, 3 moderator, 4 broadcaster int Role() { int role; int.TryParse(Arg("role"), out role); if (Arg("isBroadcaster").Equals("True", StringComparison.OrdinalIgnoreCase)) role = Math.Max(role, 4); if (Arg("isModerator").Equals("True", StringComparison.OrdinalIgnoreCase)) role = Math.Max(role, 3); if (Arg("userId") != "" && Arg("userId") == Arg("broadcastUserId")) role = 4; return role; } bool IsMod() { return Role() >= 3; } bool IsFreeUser(JObject cfg, string user) { return ((string)cfg["freeUsers"] ?? "").Split(new[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries) .Any(u => u.TrimStart('@').Equals(user, StringComparison.OrdinalIgnoreCase)); } string CostNote(JObject cfg) { long cost = (long)cfg["cost"]; return cost > 0 && (string)cfg["currencyVar"] != "" ? " | costs " + cost + " " + cfg["currencyName"] : ""; } long UserVarLong(string userId, string name) { try { object v = CPH.GetTwitchUserVarById(userId, name, true); long n; return v != null && long.TryParse(v.ToString(), out n) ? n : 0; } catch { return 0; } } long UserLong(string userId, string name) { return UserVarLong(userId, name); } long GlobalLong(string name) { try { object v = CPH.GetGlobalVar(name, true); long n; return v != null && long.TryParse(v.ToString(), out n) ? n : 0; } catch { return 0; } } static string DeviceId(JObject cfg) { return (string)cfg["deviceId"] ?? ""; } static string DeviceQuery(JObject cfg, string sep) { string id = DeviceId(cfg); return id == "" ? "" : sep + "device_id=" + Uri.EscapeDataString(id); } void Say(string msg) { CPH.SendMessage(Trunc(msg, 490), true); } static string Label(JObject t) { string artists = t["artists"] is JArray ? Artists(t) : (string)t["artists"] ?? ""; return (string)t["name"] + (artists != "" ? " by " + artists : ""); } // Spotify lists album images largest first (640, 300, 64); the 300 px one stays sharp on HiDPI thumbnails. // Podcast episodes carry their own images (and their show's), no album. static string AlbumImage(JObject track) { var images = (track.SelectToken("album.images") ?? track["images"] ?? track.SelectToken("show.images")) as JArray; if (images == null || images.Count == 0) return ""; return (string)images[Math.Min(1, images.Count - 1)]["url"] ?? ""; } // A podcast episode has no artists: its show stands in. static string Artists(JObject track) { var a = track["artists"] as JArray; return a == null ? (string)track.SelectToken("show.name") ?? "" : string.Join(", ", a.Select(x => (string)x["name"]).Take(3)); } static long Now() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } // ───────────────────────── chat messages (the responder) ───────────────────────── // Every line the bot says: key, group, what it's for, default wording, its {placeholders}, "v" when it's said to one // viewer. The window lists them all; the streamer can reword any (cfg.messages[key].text) or switch it off // (cfg.messages[key].on = false). Viewer lines go out as "@user …", or as a reply under their message. static readonly string[][] MESSAGES = { new[] { "added", "Requests", "Song added", "added {track} (#{position} in queue)", "track song artist position", "v" }, new[] { "wrongsong_removed", "Requests", "!wrongsong removed their song", "removed {track}{refund}", "track song artist refund", "v" }, new[] { "wrongsong_none", "Requests", "!wrongsong with nothing waiting", "you have no songs waiting in the queue.", "", "v" }, new[] { "where", "Requests", "!wheresmysong", "your next song, {track}, is #{position} in the queue, about {wait} away.", "track song artist position wait", "v" }, new[] { "where_playing", "Requests", "!wheresmysong while theirs plays", "{track} is playing right now.", "track song artist", "v" }, new[] { "where_none", "Requests", "!wheresmysong with nothing waiting", "you have no songs waiting. Request one with !sr .", "", "v" }, new[] { "removed_by_mod", "Requests", "Their request was removed by you or a mod", "your request {track} was removed{refund}", "track song artist refund", "v" }, new[] { "yt_failed", "Requests", "Their YouTube request couldn't play", "{track} {reason}{refund}", "track song artist reason refund", "v" }, new[] { "skip_refund", "Requests", "Their request was skipped early (refunded)", "your request {track} was skipped early, so you got {refund} back.", "track song artist refund", "v" }, new[] { "not_set_up", "Refused", "Not set up yet", "song requests aren't set up yet.", "", "v" }, new[] { "closed", "Refused", "Requests are closed", "song requests are closed right now.", "", "v" }, new[] { "access_subs", "Refused", "Subscribers and VIPs only", "song requests are for subscribers and VIPs right now.", "", "v" }, new[] { "access_mods", "Refused", "Mods only", "song requests are for mods right now.", "", "v" }, new[] { "usage", "Refused", "!sr without a song", "usage: !sr {costnote}", "costnote cost currency", "v" }, new[] { "spotify_paused", "Refused", "Spotify paused the bot", "Spotify has paused requests from this bot for a while (too many calls). Try again in {wait}.", "wait", "v" }, new[] { "cooldown", "Refused", "Cooldown", "slow down, you can request again in {seconds}s.", "seconds", "v" }, new[] { "max_per_user", "Refused", "Too many songs waiting", "you already have {songs} waiting. Use !wrongsong to swap one out.", "songs count", "v" }, new[] { "not_enough", "Refused", "Not enough points", "you need {cost} {currency} to request a song (you have {balance}).", "cost currency balance", "v" }, new[] { "too_long", "Refused", "Too long", "{track} is too long (max {max} min).", "track song artist max", "v" }, new[] { "not_playable", "Refused", "Can't play in your country", "{track} isn't playable in this region.", "track song artist", "v" }, new[] { "in_queue", "Refused", "Already in the queue", "{track} is already in the queue.", "track song artist", "v" }, new[] { "playing_now", "Refused", "Playing right now", "{track} is playing right now.", "track song artist", "v" }, new[] { "repeat", "Refused", "Played too recently", "{track} played {ago} ago. It can be requested again in {wait}.", "track song artist ago wait", "v" }, new[] { "blocked_song", "Refused", "Banned song", "{track} isn't allowed here.", "track song artist", "v" }, new[] { "blocked_artist", "Refused", "Banned artist", "songs by {artist} aren't allowed here.", "track song artist", "v" }, new[] { "explicit", "Refused", "Explicit song", "{track} is explicit, and explicit songs aren't allowed here.", "track song artist", "v" }, new[] { "spotify_link_not_found", "Refused", "Spotify link not found", "couldn't find that Spotify track.", "", "v" }, new[] { "wrong_link", "Refused", "Album, playlist or podcast link", "only Spotify track links work, not albums, playlists or podcasts.", "", "v" }, new[] { "bad_youtube_link", "Refused", "Broken YouTube link", "couldn't read that YouTube link.", "", "v" }, new[] { "bad_link", "Refused", "Some other link", "send a song name, a Spotify track link or a YouTube link.", "", "v" }, new[] { "no_results", "Refused", "Nothing found", "no Spotify results for \"{query}\".", "query", "v" }, new[] { "youtube_missing", "Refused", "YouTube video not found", "couldn't find that YouTube video (it may be private or deleted).", "", "v" }, new[] { "youtube_live", "Refused", "YouTube live stream", "live streams can't be requested.", "", "v" }, new[] { "youtube_no_embed", "Refused", "YouTube video blocked outside YouTube", "\"{song}\" can't be played outside YouTube (its owner turned that off).", "song", "v" }, new[] { "youtube_no_player", "Refused", "YouTube player not open", "the YouTube player isn't open right now and \"{song}\" isn't on Spotify, so it can't play.", "song", "v" }, new[] { "youtube_not_on_spotify", "Refused", "YouTube link, YouTube turned off", "couldn't find \"{song}\" on Spotify. Send a Spotify link or the song's name.", "song", "v" }, new[] { "now_playing", "Playing", "A request starts (if announcing is on)", "Now playing: {track} (requested by {user})", "track song artist user url", "" }, new[] { "song_request", "Playing", "!song, a request is playing", "Now playing: {track} (requested by {user}) {url}", "track song artist user url", "" }, new[] { "song_own", "Playing", "!song, your own music", "Now playing: {track} {url}", "track song artist url", "" }, new[] { "song_nothing", "Playing", "!song, nothing playing", "Nothing is playing on Spotify right now.", "", "" }, new[] { "dropped", "Playing", "A request didn't play", "{track} (requested by {user}) didn't play{refunded}", "track song artist user refunded", "" }, new[] { "queue_list", "Queue", "!queue", "Up next: {list}", "list", "" }, new[] { "queue_empty", "Queue", "!queue, empty", "The request queue is empty. Request with !sr ", "", "" }, new[] { "voteskip_vote", "Queue", "!voteskip, a vote", "{user} votes to skip {track} ({votes}/{needed}).", "user track song artist votes needed", "" }, new[] { "voteskip_done", "Queue", "!voteskip skipped the song", "Skipped {track}: {votes} votes.", "track song artist votes", "" }, new[] { "voteskip_own", "Queue", "!voteskip by the song's requester", "Skipped {track}: {user} requested it and voted to skip.", "user track song artist", "" }, new[] { "voteskip_already", "Queue", "!voteskip twice", "you already voted to skip this one.", "track song artist", "v" }, new[] { "voteskip_off", "Queue", "!voteskip while it's off", "vote skipping is off.", "", "v" }, new[] { "skipped", "Mods", "!skip", "Skipped.", "", "" }, new[] { "skip_no_device", "Mods", "!skip, Spotify isn't playing", "Spotify isn't playing on any device right now.", "", "" }, new[] { "skip_failed", "Mods", "!skip failed", "Couldn't skip (Spotify said {code}).", "code", "" }, new[] { "skip_yt_no_player", "Mods", "!skip, YouTube player closed", "The next request is a YouTube video, but the YouTube player isn't open in OBS.", "", "" }, new[] { "banned", "Mods", "!srban", "Banned {track} from requests.", "track song artist", "" }, new[] { "ban_nothing", "Mods", "!srban with nothing playing", "Nothing is playing to ban.", "", "" }, new[] { "cleared", "Mods", "Queue cleared", "Cleared the request queue ({songs}, refunded).", "songs count", "" }, new[] { "opened", "Mods", "Requests opened", "Song requests are open! Use !sr ", "", "" }, new[] { "closed_now", "Mods", "Requests closed", "Song requests are closed. Songs already in the queue will still play.", "", "" }, new[] { "reset", "Mods", "!srreset", "Song requests reset{forgot}. The next request goes to Spotify at the end of this song, or right away with !skip.", "forgot", "" }, }; static Dictionary V(params string[] pairs) { var d = new Dictionary(); for (int i = 0; i + 1 < pairs.Length; i += 2) d[pairs[i]] = pairs[i + 1] ?? ""; return d; } // {track} "Song by Artist", {song}, {artist}, {url} for a request entry, a Spotify track or a YouTube track. static Dictionary TrackVars(JObject t, Dictionary into = null) { var d = into ?? new Dictionary(); d["track"] = Label(t); d["song"] = (string)t["name"] ?? ""; d["artist"] = t["artists"] is JArray ? Artists(t) : (string)t["artists"] ?? ""; d["url"] = (string)t.SelectToken("external_urls.spotify") ?? (string)t["url"] ?? ""; return d; } static string Plural(long n, string word) { return n + " " + word + (n == 1 ? "" : "s"); } // The streamer's wording for a message, "" when they switched it off, with the {placeholders} filled in. string MsgText(string key, Dictionary v) { var def = MESSAGES.FirstOrDefault(x => x[0] == key); string text = def == null ? key : def[3]; var own = LoadConfig().SelectToken("messages." + key) as JObject; if (own != null) { if ((bool?)own["on"] == false) return ""; string t = (string)own["text"]; if (!string.IsNullOrWhiteSpace(t)) text = t; } return Regex.Replace(text, @"\{(\w+)\}", m => { string val; return v != null && v.TryGetValue(m.Groups[1].Value.ToLowerInvariant(), out val) ? val : m.Value; }).Trim(); } // Says a message in chat. toUser: it's for that viewer, as "@user …", or as a reply under the message they just // sent when the streamer chose replies. void Tell(string key, Dictionary v, string toUser = null) { string text = MsgText(key, v); if (text == "") return; if (string.IsNullOrEmpty(toUser)) { Say(text); return; } string msgId = Arg("msgId"); if ((string)LoadConfig()["replyMode"] == "reply" && msgId != "" && UserName().Equals(toUser, StringComparison.OrdinalIgnoreCase)) { try { CPH.TwitchReplyToMessage(Trunc(text, 490), msgId, true, true); return; } catch { } } Say("@" + toUser + " " + text); } static string Trunc(string s, int n) { if (s == null) return ""; return s.Length <= n ? s : s.Substring(0, n - 1) + "…"; } // ───────────────────────── settings window ───────────────────────── static readonly Color BG = Color.FromArgb(24, 25, 28); static readonly Color PANEL = Color.FromArgb(34, 36, 40); static readonly Color INPUT = Color.FromArgb(46, 48, 54); static readonly Color TEXT = Color.FromArgb(232, 232, 232); static readonly Color MUTED = Color.FromArgb(150, 154, 162); static readonly Color GREEN = Color.FromArgb(30, 185, 84); class DeviceItem { public string Id; public string Name; public override string ToString() { return Name; } } void OpenSettings() { var t = new Thread(() => { try { Form modern = null; try { modern = BuildPanelForm(); } catch (Exception ex) { CPH.LogWarn("[SpotifySR] modern window unavailable, using classic: " + ex.Message); } if (modern != null) { using (modern) Application.Run(modern); if (!"fallback".Equals(modern.Tag)) return; } using (var form = BuildSettingsForm()) Application.Run(form); } catch (Exception ex) { CPH.LogError("[SpotifySR] settings window: " + ex); } }); t.SetApartmentState(ApartmentState.STA); t.IsBackground = true; t.Start(); } [System.Runtime.InteropServices.DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); // The main window: panel.html (embedded below) in WebView2, which Streamer.bot already ships. // The page and this code talk through postMessage; see receive()/send() in ui/panel.html. Form BuildPanelForm() { var bg = Color.FromArgb(12, 13, 15); var form = new Form { Text = "Spotify Song Requests", BackColor = bg, StartPosition = FormStartPosition.CenterScreen, ClientSize = new Size(1100, 720), MinimumSize = new Size(360, 440), TopMost = true }; RestoreWindow(form); form.FormClosing += (s, e) => SaveWindow(form); form.HandleCreated += (s, e) => { int on = 1; // DWMWA_USE_IMMERSIVE_DARK_MODE: dark title bar on Windows 10 20H1+ / 11 try { DwmSetWindowAttribute(form.Handle, 20, ref on, 4); } catch { } }; form.Shown += (s, e) => { form.TopMost = false; form.Activate(); }; var web = new WebView2 { Dock = DockStyle.Fill, DefaultBackgroundColor = bg }; form.Controls.Add(web); string lastState = null; int nowBusy = 0; Action post = o => { try { if (web.CoreWebView2 != null) web.CoreWebView2.PostWebMessageAsJson(o.ToString(Newtonsoft.Json.Formatting.None)); } catch { } }; Action postLater = o => { try { if (!form.IsDisposed) form.BeginInvoke((Action)(() => post(o))); } catch { } }; Action toast = (text, error) => post(new JObject { ["type"] = "toast", ["text"] = text, ["error"] = error }); Action pushState = () => { JObject snap = null; if (!WithStateLock(2000, () => snap = LoadState())) return; var msg = StateMessage(snap); string sig = msg.ToString(Newtonsoft.Json.Formatting.None); if (sig == lastState) return; lastState = sig; post(msg); }; // What's playing is what the tick last saw: it asks Spotify anyway, and asking again every 3 s from here // helped use up Spotify's quota. Only while the tick isn't running does the window ask, at most every 15 s. // Spotify calls run off the UI thread so the window never freezes. long lastAsk = 0; Action refreshNow = () => { if (Interlocked.Exchange(ref nowBusy, 1) == 1) return; Task.Run(() => { JObject now = null; try { if (Connected()) { long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (Now() - CPH.GetGlobalVar(G_TICK_AT, false) >= 20 && ms - lastAsk >= 15000) { lastAsk = ms; int code; var cp = Api("GET", NOW_PATH, null, out code); if (code == 200 || code == 204) RememberNow(NowFromPlayback(cp)); } now = CachedNow(); } } catch (Exception ex) { CPH.LogWarn("[SpotifySR] now playing: " + ex.Message); } finally { Interlocked.Exchange(ref nowBusy, 0); } postLater(new JObject { ["type"] = "now", ["now"] = now }); }); }; Action handle = m => { string type = (string)m["type"] ?? ""; if (type == "openUrl") { string url = (string)m["url"] ?? ""; if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) OpenUrl(url); return; } if (type == "copy") { try { Clipboard.SetText((string)m["text"] ?? " "); } catch { } return; } if (type == "ready") { lastState = null; refreshNow(); } PanelCommand(m, postLater); }; web.CoreWebView2InitializationCompleted += (s, e) => { if (!e.IsSuccess) { CPH.LogWarn("[SpotifySR] WebView2 failed: " + e.InitializationException); form.Tag = "fallback"; form.Close(); return; } var core = web.CoreWebView2; core.Settings.AreDefaultContextMenusEnabled = false; core.Settings.IsZoomControlEnabled = false; core.Settings.IsStatusBarEnabled = false; core.Settings.AreDevToolsEnabled = false; core.NewWindowRequested += (s2, e2) => { e2.Handled = true; if (e2.Uri.StartsWith("https://")) OpenUrl(e2.Uri); }; core.NavigationStarting += (s2, e2) => { if (!e2.Uri.StartsWith("data:") && !e2.Uri.StartsWith("about:")) { e2.Cancel = true; if (e2.Uri.StartsWith("https://")) OpenUrl(e2.Uri); } }; core.WebMessageReceived += (s2, e2) => { JObject m; try { m = JObject.Parse(e2.WebMessageAsJson); } catch { return; } try { handle(m); } catch (Exception ex) { CPH.LogError("[SpotifySR] window: " + ex); toast("Something went wrong: " + ex.Message, true); } }; core.NavigateToString(PANEL_HTML); }; form.Load += async (s, e) => { try { string dataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SpotifySR", "WebView2"); var env = await CoreWebView2Environment.CreateAsync(null, dataDir); await web.EnsureCoreWebView2Async(env); } catch (Exception ex) { CPH.LogWarn("[SpotifySR] WebView2 unavailable: " + ex.Message); form.Tag = "fallback"; form.Close(); } }; int ticks = 0; var timer = new System.Windows.Forms.Timer { Interval = 1500 }; timer.Tick += (s, e) => { if (web.CoreWebView2 == null) return; if (ticks % 10 == 0) KeepTickOn(); pushState(); if (++ticks % 2 == 0) refreshNow(); }; timer.Start(); form.FormClosed += (s, e) => timer.Dispose(); return form; } const string G_WINDOW = "spotifySR_window"; const string G_NOW = "spotifySR_now"; // One handler for both front ends: the WebView2 window (reply = postMessage) and OBS docks // (reply = WebSocket broadcast). Slow Spotify calls run on a worker and reply when done. void PanelCommand(JObject m, Action reply) { string type = (string)m["type"] ?? ""; Func toast = (text, error) => new JObject { ["type"] = "toast", ["text"] = text, ["error"] = error }; Action replyState = () => { JObject st = null; if (WithStateLock(3000, () => st = LoadState())) reply(StateMessage(st)); }; if (type == "ready") { reply(ConfigMessage()); replyState(); reply(new JObject { ["type"] = "now", ["now"] = CachedNow() }); } else if (type == "move") { long seq = (long?)m["seq"] ?? -1; int to = (int?)m["to"] ?? 0; WithStateLock(3000, () => { var st = LoadState(); var q = (JArray)st["queue"]; int i = IndexOfSeq(q, seq); if (i < 0) return; to = Math.Max(0, Math.Min(q.Count - 1, to)); if (i == to) return; var token = q[i]; q.RemoveAt(i); q.Insert(to, token); SaveState(st); }); replyState(); } else if (type == "remove") { long seq = (long?)m["seq"] ?? -1; JObject removed = null; WithStateLock(3000, () => { var st = LoadState(); var q = (JArray)st["queue"]; int i = IndexOfSeq(q, seq); if (i < 0) return; removed = (JObject)q[i]; q.RemoveAt(i); SaveState(st); }); if (removed != null) { var c = LoadConfig(); Refund(removed, c); Tell("removed_by_mod", TrackVars(removed, V("refund", RefundNote(removed, c))), (string)removed["user"]); reply(toast("Removed " + removed["name"] + " and refunded " + removed["user"], false)); } replyState(); } else if (type == "ban") { long seq = (long?)m["seq"] ?? -1; JObject removed = null; WithStateLock(3000, () => { var st = LoadState(); var q = (JArray)st["queue"]; int i = IndexOfSeq(q, seq); if (i < 0) return; removed = (JObject)q[i]; q.RemoveAt(i); SaveState(st); }); if (removed != null) { var c = LoadConfig(); Refund(removed, c); Ban(removed); Tell("removed_by_mod", TrackVars(removed, V("refund", RefundNote(removed, c))), (string)removed["user"]); reply(toast("Banned " + removed["name"] + " and refunded " + removed["user"], false)); } replyState(); } else if (type == "newPlaylist") { var c = LoadConfig(); c["playlistId"] = ""; c["playlistUrl"] = ""; SaveConfig(c); CPH.SetGlobalVar(G_PLAYLIST_ADDED, "[]", true); reply(toast("The next request that plays starts a new playlist", false)); reply(ConfigMessage()); } else if (type == "clear") { var removed = new List(); WithStateLock(3000, () => { var st = LoadState(); removed.AddRange(((JArray)st["queue"]).Cast()); st["queue"] = new JArray(); SaveState(st); }); var c = LoadConfig(); foreach (var e in removed) Refund(e, c); if (removed.Count > 0) Tell("cleared", V("songs", Plural(removed.Count, "song"), "count", removed.Count.ToString())); reply(toast("Cleared " + removed.Count + " request" + (removed.Count == 1 ? "" : "s") + ", everyone refunded", false)); replyState(); } else if (type == "setOpen") { bool open = (bool?)m["open"] ?? true; WithStateLock(3000, () => { var st = LoadState(); st["enabled"] = open; SaveState(st); }); Tell(open ? "opened" : "closed_now", null); reply(toast(open ? "Requests are open" : "Requests are closed", false)); replyState(); } else if (type == "skip") { Task.Run(() => { var c = LoadConfig(); int code; string yt = null; WithStateLock(15000, () => { SettleBeforeSkip(true); yt = SkipYouTube(c); }); if (yt != null) { reply(toast(yt == "skipped" ? "Skipped" : "The next request is a YouTube video, but the YouTube player isn't open", yt != "skipped")); return; } WithStateLock(15000, () => HandNextForSkip(c)); // the skip lands on the next request Api("POST", "/me/player/next" + DeviceQuery(c, "?"), "", out code); if (code == 404 && DeviceId(c) != "") Api("POST", "/me/player/next", "", out code); bool ok = code >= 200 && code < 300; reply(toast(ok ? "Skipped" : code == 429 ? "Spotify has paused calls from this app for " + HowLong(BackoffLeft()) : code == 404 ? "Spotify isn't playing on any device" : "Couldn't skip (Spotify said " + code + ")", !ok)); if (!ok) return; PollSoon(); Thread.Sleep(800); int c2; var cp = Api("GET", NOW_PATH, null, out c2); if (c2 == 200 || c2 == 204) reply(new JObject { ["type"] = "now", ["now"] = NowFromPlayback(cp) }); }); } else if (type == "reset") { string forgot = ResetStuck(); reply(toast(forgot != "" ? "Stopped waiting for " + forgot + ". The next request goes over at the end of this song." : "Reset. Nothing was waiting on Spotify.", false)); replyState(); } else if (type == "saveConfig") { var incoming = m["config"] as JObject; if (incoming == null) return; var c = LoadConfig(); foreach (var key in new[] { "clientId", "deviceId", "deviceName", "currencyVar", "currencyName", "freeUsers" }) if (incoming[key] != null) c[key] = ((string)incoming[key] ?? "").Trim(); foreach (var key in new[] { "cost", "cooldown", "maxMinutes", "maxPerUser" }) if (incoming[key] != null) c[key] = Math.Max(0L, (long?)incoming[key] ?? 0L); if (incoming["announce"] != null) c["announce"] = (bool)incoming["announce"]; if (incoming["youtube"] != null) c["youtube"] = (string)incoming["youtube"] == "spotify" ? "spotify" : "player"; if (incoming["replyMode"] != null) c["replyMode"] = (string)incoming["replyMode"] == "reply" ? "reply" : "mention"; if (incoming["access"] != null) c["access"] = new[] { "subs", "mods" }.Contains((string)incoming["access"]) ? (string)incoming["access"] : "everyone"; foreach (var key in new[] { "costSub", "costVip", "maxPerUserSub" }) if (incoming[key] != null) c[key] = Math.Max(-1L, (long?)incoming[key] ?? -1L); foreach (var key in new[] { "noRepeatMinutes", "voteSkip" }) if (incoming[key] != null) c[key] = Math.Max(0L, (long?)incoming[key] ?? 0L); if (incoming["refundSkipPercent"] != null) c["refundSkipPercent"] = Math.Max(0L, Math.Min(100L, (long?)incoming["refundSkipPercent"] ?? 0L)); foreach (var key in new[] { "blockExplicit", "playlist" }) if (incoming[key] != null) c[key] = (bool)incoming[key]; if (incoming["blockArtists"] != null) c["blockArtists"] = Trunc(((string)incoming["blockArtists"] ?? "").Trim(), 2000); if (incoming["playlistName"] != null) c["playlistName"] = Trunc(((string)incoming["playlistName"] ?? "").Trim(), 100); if ((string)c["playlistName"] == "") c["playlistName"] = "Stream requests"; if (incoming["blockSongs"] is JArray) // the settings list can only take songs off it { var keep = new HashSet(((JArray)incoming["blockSongs"]).Select(x => (string)x["uri"] ?? "")); c["blockSongs"] = new JArray(((JArray)c["blockSongs"]).Where(x => keep.Contains((string)x["uri"] ?? ""))); } if (incoming["messages"] is JObject) { var msgs = new JObject(); foreach (var prop in ((JObject)incoming["messages"]).Properties()) { var def = MESSAGES.FirstOrDefault(x => x[0] == prop.Name); var o = prop.Value as JObject; if (def == null || o == null) continue; string text = Trunc(((string)o["text"] ?? "").Trim(), 450); bool on = (bool?)o["on"] ?? true; if ((text == "" || text == def[3]) && on) continue; // the default: nothing to keep var keep = new JObject { ["on"] = on }; if (text != "" && text != def[3]) keep["text"] = text; msgs[prop.Name] = keep; } c["messages"] = msgs; } if ((string)c["currencyName"] == "") c["currencyName"] = "points"; SaveConfig(c); reply(toast("Settings saved", false)); reply(ConfigMessage()); } else if (type == "devices") { Task.Run(() => { var res = Api("GET", "/me/player/devices", null); var list = new JArray(); var arr = res == null ? null : res["devices"] as JArray; if (arr != null) foreach (var d in arr) list.Add(new JObject { ["id"] = (string)d["id"] ?? "", ["name"] = (string)d["name"] + " (" + d["type"] + ")" }); reply(new JObject { ["type"] = "devices", ["devices"] = list }); if (res == null) reply(toast("Couldn't load devices. Is Spotify connected?", true)); }); } else if (type == "connect") { string id = ((string)m["clientId"] ?? "").Trim(); var c = LoadConfig(); c["clientId"] = id; SaveConfig(c); Task.Run(() => { string result = Authorize(id); reply(new JObject { ["type"] = "conn", ["connected"] = Connected() && result.StartsWith("Connected"), ["account"] = CPH.GetGlobalVar(G_ACCOUNT, true) ?? "", ["message"] = result }); reply(ConfigMessage()); }); } else if (type == "openWindow") { OpenSettings(); } else if (type.StartsWith("yt-")) { YouTubePlayerMessage(type, m, reply); } } // From the YouTube player page. It checks in every 15 s with its id and when it was opened. Only one player is in // charge (so a second OBS or a browser tab never plays the same video twice): the newest one opened, except that // a video that's playing stays with its player. A closed player stops checking in and is dropped after 90 s. void YouTubePlayerMessage(string type, JObject m, Action reply) { string id = (string)m["id"] ?? ""; var player = YtPlayer(); if (type == "yt-hello") { long since = (long?)m["since"] ?? 0; // ...or the one in charge missed a check-in (closed or reloaded): the newcomer carries on its video bool takeOver = player != null && (string)player["id"] != id && ((since > ((long?)player["since"] ?? 0) && !IsYouTube(LoadState()["playing"] as JObject)) || NowMs() - ((long?)player["at"] ?? 0) > 60000); if (player == null || (string)player["id"] == id || takeOver) { bool wasGone = player == null; if (takeOver) CPH.LogInfo("[SpotifySR] YouTube: a newer player was opened; it takes over"); player = new JObject { ["id"] = id, ["at"] = NowMs(), ["since"] = since, ["version"] = m["version"] }; CPH.SetGlobalVar(G_YT_PLAYER, player.ToString(Newtonsoft.Json.Formatting.None), false); if (wasGone) CPH.LogInfo("[SpotifySR] YouTube player connected"); var st = LoadState(); var cur = st["playing"] as JObject; // it was reloaded mid-video (OBS scene switch, refresh): carry on where it was if (IsYouTube(cur) && (bool?)m["idle"] == true) { var now = YouTubeNow(st); Broadcast(new JObject { ["type"] = "yt-play", ["player"] = id, ["videoId"] = cur["videoId"], ["seq"] = cur["seq"], ["startMs"] = now == null ? 0 : (long)now["progressMs"] }); } if (wasGone) { PollSoon(); // YouTube requests may have been waiting for it Broadcast(StateMessage(st)); // docks drop their "player isn't open" note } } // with what should be playing, so a player that lost touch for a while stops a video the bot moved on from var on = LoadState()["playing"] as JObject; reply(new JObject { ["type"] = "yt-hello", ["player"] = player == null ? "" : (string)player["id"], ["videoId"] = IsYouTube(on) ? (string)on["videoId"] : "" }); return; } if (player == null || (string)player["id"] != id) return; // not the one in charge var state = LoadState(); var playing = state["playing"] as JObject; if (!IsYouTube(playing) || (string)playing["videoId"] != (string)m["videoId"]) return; // about another video var cfg = LoadConfig(); if (type == "yt-state") { long dur = (long?)m["durationMs"] ?? 0; CPH.SetGlobalVar(G_YT_NOW, new JObject { ["videoId"] = m["videoId"], ["progressMs"] = (long?)m["progressMs"] ?? 0, ["durationMs"] = dur, ["isPlaying"] = (string)m["state"] != "paused", ["at"] = NowMs() }.ToString(Newtonsoft.Json.Formatting.None), false); long maxMinutes = (long)cfg["maxMinutes"]; if (dur > 0 && maxMinutes > 0 && dur > maxMinutes * 60000 + 2000) { FinishYouTube(state, cfg, "is too long (max " + maxMinutes + " min), so it was skipped"); return; } if (dur > 0 && ((long?)playing["durationMs"] ?? 0) == 0) { playing["durationMs"] = dur; SaveState(state); } BroadcastNow(state); } else if (type == "yt-ended") { FinishYouTube(state, cfg, null); } else if (type == "yt-error") { CPH.LogWarn("[SpotifySR] YouTube: " + Label(playing) + " failed with player error " + m["code"]); FinishYouTube(state, cfg, YouTubeError((long?)m["code"] ?? 0)); } } JObject StateMessage(JObject st) { long limited = BackoffLeft() > 0 ? GlobalLong(G_BACKOFF) : 0; // unix seconds Spotify said to wait until return new JObject { ["type"] = "state", ["queue"] = st["queue"], ["pushed"] = st["pushed"], ["playing"] = st["playing"], ["enabled"] = st["enabled"], ["ticking"] = TickAlive(), ["limitedUntil"] = limited, ["ytPlayer"] = YtPlayer() != null }; } JObject ConfigMessage() { return new JObject { ["type"] = "config", ["version"] = VERSION, ["redirectUri"] = REDIRECT_URI, ["connected"] = Connected(), ["account"] = CPH.GetGlobalVar(G_ACCOUNT, true) ?? "", ["dockUrl"] = DockUrl(), ["ytPlayerUrl"] = YT_PLAYER_URL, ["playlistAllowed"] = PlaylistAllowed(), ["messageList"] = new JArray(MESSAGES.Select(x => new JObject { ["key"] = x[0], ["group"] = x[1], ["label"] = x[2], ["text"] = x[3], ["vars"] = x[4], ["viewer"] = x[5] == "v" })), ["config"] = LoadConfig() }; } static JObject NowFromPlayback(JObject cp) { var item = cp == null ? null : cp["item"] as JObject; if (item == null) return null; return new JObject { ["uri"] = (string)item["uri"], ["name"] = (string)item["name"], ["artists"] = Artists(item), ["image"] = AlbumImage(item), ["progressMs"] = (long?)cp["progress_ms"] ?? 0, ["durationMs"] = (long?)item["duration_ms"] ?? 0, ["isPlaying"] = (bool?)cp["is_playing"] ?? false }; } void RememberNow(JToken now) { CPH.SetGlobalVar(G_NOW, new JObject { ["now"] = now, ["at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }.ToString(Newtonsoft.Json.Formatting.None), false); } // What the last Tick saw, with the progress moved on by the time since. JObject CachedNow() { var cached = ParseObj(CPH.GetGlobalVar(G_NOW, false)); var now = cached["now"] as JObject; if (now == null) return null; if ((bool?)now["isPlaying"] == true) { long age = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ((long?)cached["at"] ?? 0); now["progressMs"] = Math.Min((long?)now["durationMs"] ?? 0, ((long?)now["progressMs"] ?? 0) + Math.Max(0, age)); } return now; } // Goes to every client of Streamer.bot's WebSocket server as a General.Custom event; the dock page // picks out the ones marked source = SpotifySR. void Broadcast(JObject msg) { msg["source"] = "SpotifySR"; try { CPH.WebsocketBroadcastJson(msg.ToString(Newtonsoft.Json.Formatting.None)); } catch { } } // The OBS dock page is hosted; it connects back to this Streamer.bot over its WebSocket server. const string DOCK_URL = "https://byttenapple.com/assets/song-requests/dock"; static string DockUrl() { return DOCK_URL; } // Reopen where the streamer left it (e.g. docked beside OBS), if that spot is still on a screen. void RestoreWindow(Form form) { var parts = (CPH.GetGlobalVar(G_WINDOW, true) ?? "").Split(','); int x, y, w, h; if (parts.Length < 4 || !int.TryParse(parts[0], out x) || !int.TryParse(parts[1], out y) || !int.TryParse(parts[2], out w) || !int.TryParse(parts[3], out h)) return; var bounds = new Rectangle(x, y, Math.Max(w, form.MinimumSize.Width), Math.Max(h, form.MinimumSize.Height)); var title = new Rectangle(bounds.X + 40, bounds.Y, Math.Max(1, bounds.Width - 80), 30); if (!Screen.AllScreens.Any(sc => sc.WorkingArea.IntersectsWith(title))) return; form.StartPosition = FormStartPosition.Manual; form.Bounds = bounds; if (parts.Length > 4 && parts[4] == "max") form.WindowState = FormWindowState.Maximized; } void SaveWindow(Form form) { var b = form.WindowState == FormWindowState.Normal ? form.Bounds : form.RestoreBounds; if (b.Width <= 0 || b.Height <= 0) return; CPH.SetGlobalVar(G_WINDOW, b.X + "," + b.Y + "," + b.Width + "," + b.Height + (form.WindowState == FormWindowState.Maximized ? ",max" : ""), true); } // generated from ui/panel.html by embed-ui.js: edit that file, not this string. const string PANEL_HTML = @" Song Requests
Connecting to Streamer.bot…
♪
Now playing
Nothing playing
Start something in Spotify. Requests play after the current song.
0:00
0:00
Your music
Requests are closed. Songs already in the queue still play.
Requests won't play on their own: the Spotify SR Tick timer isn't running. In Streamer.bot, open Settings → Timed Actions and turn it on (and check the action Spotify SR - Tick is enabled).
YouTube requests are waiting, but the YouTube player isn't open in OBS. Add it under Setup → YouTube; they start once it's open.

Queue

#SongRequested byTimePaid
The queue is empty Viewers request songs with !sr song name, a Spotify link or a YouTube link.

Rules

Who can request, what it costs, and the limits.

Price

Charge viewers from your points system. Channel point rewards use their own cost instead.

Who can request

Give subscribers and VIPs their own price and limit, or keep requests to them. Mods and you can always request.

Limits

Keep the queue varied. The broadcaster ignores these limits.

Moderation

Bans, repeats and vote skipping.

Bans

A request that hits a ban is turned down and nobody pays.

Fair play

Keep the same songs from coming back, and let chat skip what it doesn't like.

%

Chat

Everything the bot says, in your words.

How the bot talks

How replies reach a viewer, and whether a request starting is announced.

Messages

Everything the bot says in chat. Change the wording or switch a message off. Words in {braces} are filled in for you.

Update Song Requests to 1.8 or newer to edit what the bot says.

Setup

Connect Spotify and set up the YouTube player, the OBS dock and the playlist.

1Spotify connection

Requests play through your own Spotify account. You need Spotify Premium and a free developer app.

  1. Open the Spotify developer dashboard. Reuse your app if you have one, or create one and tick Web API.
  2. Under Redirect URIs, add the address below, click Add, then Save.
  3. Paste the app's Client ID here and press Connect Spotify.
Not connected

2Playback device

Where requested songs play. Open Spotify and play something so your device shows up.

3YouTube

Viewers can request YouTube links, also songs Spotify doesn't have. They play on the YouTube player, a hidden page in OBS: when it's their turn Spotify pauses, the video's sound plays, then Spotify carries on.

  1. In OBS, add a Browser source to your main scene, paste the link above and set it to 300 × 100. It shows nothing on stream.
  2. Untick Shutdown source when not visible, so it keeps playing in every scene.
  3. Leave Control audio via OBS unticked: it then plays through your desktop audio, like Spotify.
If YouTube keeps refusing videos there (it may want a signed-in player: ""confirm you're not a bot""), open the same link in your normal browser where you're signed in to YouTube instead, click Allow sound once and keep that tab open. Refused videos are always refunded. It needs Streamer.bot's WebSocket server: Servers/Clients → WebSocket Server, Auto Start on.

4Request playlist

Every Spotify request that plays is added to a private playlist in your Spotify, so you can find those songs again.

5OBS dock

Put this panel inside OBS so you can manage requests without leaving it. It talks to Streamer.bot through its WebSocket server.

  1. In Streamer.bot, open Servers/Clients → WebSocket Server. Tick Auto Start and press Start Server.
  2. Check the port below matches it. If you set a password there, enter it too.
  3. In OBS, open Docks → Custom Browser Docks. Name it Song Requests, paste the URL and click Apply. The dock is hosted on byttenapple.com and updates by itself; it only ever talks to the Streamer.bot on your own PC.
The password is saved in the URL, so only paste it into your own OBS.
Unsaved changes

"; //
// Classic WinForms window, used when WebView2 can't start. Form BuildSettingsForm() { var cfg = LoadConfig(); var st = LoadState(); var form = new Form { Text = "Spotify Song Requests " + VERSION, BackColor = BG, ForeColor = TEXT, Font = new Font("Segoe UI", 9.5f), FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, StartPosition = FormStartPosition.CenterScreen, ClientSize = new Size(560, 700), TopMost = true }; form.Shown += (s, e) => { form.TopMost = false; form.Activate(); }; var root = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, AutoScroll = true, Padding = new Padding(18, 14, 18, 14) }; form.Controls.Add(root); const int W = 510; Func heading = txt => new Label { Text = txt, AutoSize = false, Width = W, Height = 30, Font = new Font("Segoe UI Semibold", 11.5f), ForeColor = TEXT, TextAlign = ContentAlignment.BottomLeft, Margin = new Padding(0, 10, 0, 4) }; Func note = txt => new Label { Text = txt, AutoSize = false, Width = W, Height = 36, ForeColor = MUTED, Margin = new Padding(0, 0, 0, 6) }; Func box = () => new TextBox { Width = 300, BackColor = INPUT, ForeColor = TEXT, BorderStyle = BorderStyle.FixedSingle }; Func button = (txt, color) => { var b = new Button { Text = txt, AutoSize = true, Height = 30, FlatStyle = FlatStyle.Flat, BackColor = color, ForeColor = color == GREEN ? Color.Black : TEXT, Padding = new Padding(10, 0, 10, 0), Margin = new Padding(0, 0, 8, 0), Cursor = Cursors.Hand }; b.FlatAppearance.BorderSize = 0; return b; }; Func row = (label, control) => { var p = new FlowLayoutPanel { Width = W, Height = 34, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, Margin = new Padding(0, 0, 0, 2) }; p.Controls.Add(new Label { Text = label, Width = 195, Height = 28, TextAlign = ContentAlignment.MiddleLeft, ForeColor = TEXT }); control.Margin = new Padding(0, 3, 0, 0); p.Controls.Add(control); return p; }; Func number = (value, min, max) => new NumericUpDown { Minimum = min, Maximum = max, Value = Math.Max(min, Math.Min(max, value)), Width = 100, BackColor = INPUT, ForeColor = TEXT, BorderStyle = BorderStyle.FixedSingle }; // 1. Spotify app root.Controls.Add(heading("1. Spotify connection")); var steps = new Label { AutoSize = false, Width = W, Height = 76, ForeColor = MUTED, Text = "Create an app on the Spotify developer dashboard (you need Spotify Premium). " + "Tick \"Web API\", paste the redirect URI below exactly, save, then copy the app's Client ID here and press Connect." }; root.Controls.Add(steps); var dash = new LinkLabel { Text = "Open developer.spotify.com/dashboard", AutoSize = true, LinkColor = GREEN, ActiveLinkColor = GREEN, Margin = new Padding(0, 0, 0, 8) }; dash.LinkClicked += (s, e) => OpenUrl("https://developer.spotify.com/dashboard"); root.Controls.Add(dash); var redirect = box(); redirect.Width = 215; redirect.Text = REDIRECT_URI; redirect.ReadOnly = true; var copy = button("Copy", PANEL); copy.Click += (s, e) => { try { Clipboard.SetText(REDIRECT_URI); copy.Text = "Copied"; } catch { } }; var redirectRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; redirectRow.Controls.Add(redirect); redirectRow.Controls.Add(copy); root.Controls.Add(row("Redirect URI", redirectRow)); var clientId = box(); clientId.Text = (string)cfg["clientId"]; root.Controls.Add(row("Client ID", clientId)); var connect = button("Connect Spotify", GREEN); var status = new Label { AutoSize = false, Width = W - 170, Height = 30, TextAlign = ContentAlignment.MiddleLeft, ForeColor = MUTED }; string account = CPH.GetGlobalVar(G_ACCOUNT, true); status.Text = Connected() ? "Connected" + (string.IsNullOrEmpty(account) ? "" : " as " + account) : "Not connected"; var connectRow = new FlowLayoutPanel { Width = W, Height = 40, WrapContents = false, Margin = new Padding(0, 6, 0, 0) }; connectRow.Controls.Add(connect); connectRow.Controls.Add(status); root.Controls.Add(connectRow); // 2. Playback root.Controls.Add(heading("2. Playback device")); var devices = new ComboBox { Width = 215, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = INPUT, ForeColor = TEXT, FlatStyle = FlatStyle.Flat }; var activeItem = new DeviceItem { Id = "", Name = "Whichever device is playing" }; devices.Items.Add(activeItem); if ((string)cfg["deviceId"] != "") devices.Items.Add(new DeviceItem { Id = (string)cfg["deviceId"], Name = (string)cfg["deviceName"] }); devices.SelectedIndex = devices.Items.Count - 1; var refreshDevices = button("Refresh", PANEL); var deviceRow = new FlowLayoutPanel { AutoSize = true, WrapContents = false, Margin = new Padding(0) }; deviceRow.Controls.Add(devices); deviceRow.Controls.Add(refreshDevices); root.Controls.Add(row("Play requests on", deviceRow)); root.Controls.Add(note("Open Spotify and play something so your device shows up, then press Refresh.")); Action loadDevices = () => { var res = Api("GET", "/me/player/devices", null); var arr = res == null ? null : res["devices"] as JArray; string keep = ((DeviceItem)devices.SelectedItem ?? activeItem).Id; devices.Items.Clear(); devices.Items.Add(activeItem); if (arr != null) foreach (var d in arr) devices.Items.Add(new DeviceItem { Id = (string)d["id"] ?? "", Name = (string)d["name"] + " (" + d["type"] + ")" }); int idx = 0; for (int i = 0; i < devices.Items.Count; i++) if (((DeviceItem)devices.Items[i]).Id == keep) idx = i; devices.SelectedIndex = idx; }; refreshDevices.Click += (s, e) => { if (Connected()) loadDevices(); else status.Text = "Connect Spotify first"; }; // 3. Requests root.Controls.Add(heading("3. Requests")); var cost = number((long)cfg["cost"], 0, 1000000); root.Controls.Add(row("Cost per request", cost)); var currencyVar = box(); currencyVar.Text = (string)cfg["currencyVar"]; root.Controls.Add(row("Currency user variable", currencyVar)); var currencyName = box(); currencyName.Text = (string)cfg["currencyName"]; root.Controls.Add(row("Currency name in chat", currencyName)); root.Controls.Add(note("The cost is taken from a persisted Twitch user variable (for example your points system). Leave the variable empty for free requests. Channel point rewards use their own cost.")); var cooldown = number((long)cfg["cooldown"], 0, 86400); root.Controls.Add(row("Cooldown per viewer (sec)", cooldown)); var maxMinutes = number((long)cfg["maxMinutes"], 0, 600); root.Controls.Add(row("Max song length (min)", maxMinutes)); var maxPerUser = number((long)cfg["maxPerUser"], 0, 100); root.Controls.Add(row("Max songs per viewer", maxPerUser)); var freeUsers = box(); freeUsers.Text = (string)cfg["freeUsers"]; root.Controls.Add(row("Free users (comma list)", freeUsers)); var announce = new CheckBox { Text = "Announce requested songs in chat when they start", Checked = (bool)cfg["announce"], AutoSize = true, ForeColor = TEXT, Margin = new Padding(0, 6, 0, 0) }; root.Controls.Add(announce); var open = new CheckBox { Text = "Requests are open", Checked = (bool)st["enabled"], AutoSize = true, ForeColor = TEXT, Margin = new Padding(0, 4, 0, 0) }; root.Controls.Add(open); // Save var save = button("Save", GREEN); var close = button("Close", PANEL); var saved = new Label { AutoSize = false, Width = 200, Height = 30, TextAlign = ContentAlignment.MiddleLeft, ForeColor = MUTED }; var bottom = new FlowLayoutPanel { Width = W, Height = 44, WrapContents = false, Margin = new Padding(0, 16, 0, 0) }; bottom.Controls.Add(save); bottom.Controls.Add(close); bottom.Controls.Add(saved); root.Controls.Add(bottom); Action saveAll = () => { var c = LoadConfig(); c["clientId"] = clientId.Text.Trim(); var dev = (DeviceItem)devices.SelectedItem ?? activeItem; c["deviceId"] = dev.Id; c["deviceName"] = dev.Id == "" ? "" : dev.Name; c["cost"] = (long)cost.Value; c["currencyVar"] = currencyVar.Text.Trim(); c["currencyName"] = currencyName.Text.Trim() == "" ? "points" : currencyName.Text.Trim(); c["cooldown"] = (long)cooldown.Value; c["maxMinutes"] = (long)maxMinutes.Value; c["maxPerUser"] = (long)maxPerUser.Value; c["freeUsers"] = freeUsers.Text.Trim(); c["announce"] = announce.Checked; SaveConfig(c); var s2 = LoadState(); s2["enabled"] = open.Checked; SaveState(s2); }; save.Click += (s, e) => { saveAll(); saved.Text = "Saved " + DateTime.Now.ToString("HH:mm:ss"); }; close.Click += (s, e) => form.Close(); connect.Click += (s, e) => { string id = clientId.Text.Trim(); if (!Regex.IsMatch(id, "^[0-9a-fA-F]{32}$")) { status.Text = "That doesn't look like a Client ID (32 characters)"; return; } saveAll(); connect.Enabled = false; status.Text = "Waiting for you to approve in the browser…"; var worker = new Thread(() => { string result = Authorize(id); try { form.BeginInvoke((Action)(() => { connect.Enabled = true; status.Text = result; if (Connected()) loadDevices(); })); } catch { } }); worker.IsBackground = true; worker.Start(); }; // Queue page: see what's waiting, reorder it, remove songs. var queuePage = new Panel { Dock = DockStyle.Fill, Visible = false, BackColor = BG }; var nowLabel = new Label { Location = new Point(18, 12), Size = new Size(524, 46), ForeColor = MUTED }; var list = new ListView { Location = new Point(18, 62), Size = new Size(524, 450), View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false, BackColor = INPUT, ForeColor = TEXT, BorderStyle = BorderStyle.None, HeaderStyle = ColumnHeaderStyle.Nonclickable, OwnerDraw = true }; list.Columns.Add("#", 34); list.Columns.Add("Song", 220); list.Columns.Add("Requested by", 120); list.Columns.Add("Length", 58); list.Columns.Add("Paid", -2); // -2 stretches the last column so no unpainted header strip is left list.DrawColumnHeader += (s, e) => { using (var b = new SolidBrush(PANEL)) e.Graphics.FillRectangle(b, e.Bounds); TextRenderer.DrawText(e.Graphics, e.Header.Text, list.Font, new Rectangle(e.Bounds.X + 4, e.Bounds.Y, e.Bounds.Width - 4, e.Bounds.Height), MUTED, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); }; list.DrawItem += (s, e) => { e.DrawDefault = true; }; list.DrawSubItem += (s, e) => { e.DrawDefault = true; }; var up = button("▲ Up", PANEL); var down = button("▼ Down", PANEL); var top = button("Play next", PANEL); var remove = button("Remove + refund", PANEL); var skip = button("Skip song", PANEL); var queueButtons = new FlowLayoutPanel { Location = new Point(18, 522), Size = new Size(524, 36), WrapContents = false }; queueButtons.Controls.AddRange(new Control[] { up, down, top, remove, skip }); var queueStatus = new Label { Location = new Point(18, 562), Size = new Size(524, 24), ForeColor = MUTED }; var queueNote = new Label { Location = new Point(18, 588), Size = new Size(524, 44), ForeColor = MUTED, Text = "Requests go to Spotify one at a time, about 30 seconds before the current song ends (or right away with !skip). Once a song has gone to Spotify it can't be moved." }; queuePage.Controls.AddRange(new Control[] { nowLabel, list, queueButtons, queueStatus, queueNote }); string lastSignature = null; Func selectedSeq = () => list.SelectedItems.Count > 0 ? (long)list.SelectedItems[0].Tag : -1; Action refreshQueue = force => { JObject snapshot = null; if (!WithStateLock(2000, () => snapshot = LoadState())) return; string signature = snapshot.ToString(Newtonsoft.Json.Formatting.None); if (!force && signature == lastSignature) return; lastSignature = signature; var c = LoadConfig(); var playingNow = snapshot["playing"] as JObject; var handedOver = snapshot["pushed"] as JObject; nowLabel.Text = (playingNow != null ? "Now playing: " + Label(playingNow) + " (" + playingNow["user"] + ")" : "Now playing: your own music") + "\n" + (handedOver != null ? "Up next in Spotify: " + Label(handedOver) + " (" + handedOver["user"] + ")" : "Nothing handed to Spotify yet"); long keep = selectedSeq(); int topIndex = list.TopItem != null ? list.TopItem.Index : 0; var waiting = (JArray)snapshot["queue"]; list.BeginUpdate(); list.Items.Clear(); int n = 0; foreach (JObject e in waiting) { n++; var item = new ListViewItem(new[] { n.ToString(), Label(e), (string)e["user"], Duration((long?)e["durationMs"] ?? 0), PaidText(e, c) }); item.Tag = (long)e["seq"]; list.Items.Add(item); if ((long)e["seq"] == keep) item.Selected = true; } list.EndUpdate(); if (topIndex > 0 && topIndex < list.Items.Count) { try { list.TopItem = list.Items[topIndex]; } catch { } } if (waiting.Count == 0) queueStatus.Text = (bool)snapshot["enabled"] ? "The queue is empty." : "The queue is empty and requests are closed."; }; Action move = target => { long seq = selectedSeq(); if (seq < 0) { queueStatus.Text = "Select a song first."; return; } bool ok = WithStateLock(3000, () => { var s2 = LoadState(); var q = (JArray)s2["queue"]; int i = IndexOfSeq(q, seq); if (i < 0) return; int j = target == int.MinValue ? 0 : Math.Max(0, Math.Min(q.Count - 1, i + target)); if (i == j) return; var token = q[i]; q.RemoveAt(i); q.Insert(j, token); SaveState(s2); }); queueStatus.Text = ok ? "" : "Busy, try again."; refreshQueue(true); }; up.Click += (s, e) => move(-1); down.Click += (s, e) => move(1); top.Click += (s, e) => move(int.MinValue); list.KeyDown += (s, e) => { if (e.Control && e.KeyCode == Keys.Up) { move(-1); e.Handled = true; } else if (e.Control && e.KeyCode == Keys.Down) { move(1); e.Handled = true; } else if (e.KeyCode == Keys.Delete) { remove.PerformClick(); e.Handled = true; } }; remove.Click += (s, e) => { long seq = selectedSeq(); if (seq < 0) { queueStatus.Text = "Select a song first."; return; } JObject removed = null; WithStateLock(3000, () => { var s2 = LoadState(); var q = (JArray)s2["queue"]; int i = IndexOfSeq(q, seq); if (i < 0) return; removed = (JObject)q[i]; q.RemoveAt(i); SaveState(s2); }); if (removed != null) { var c = LoadConfig(); Refund(removed, c); Tell("removed_by_mod", TrackVars(removed, V("refund", RefundNote(removed, c))), (string)removed["user"]); queueStatus.Text = "Removed " + Label(removed) + "."; } refreshQueue(true); }; skip.Click += (s, e) => { var c = LoadConfig(); int code; Api("POST", "/me/player/next" + DeviceQuery(c, "?"), "", out code); if (code == 404 && DeviceId(c) != "") Api("POST", "/me/player/next", "", out code); queueStatus.Text = code >= 200 && code < 300 ? "Skipped." : code == 404 ? "Spotify isn't playing on any device." : "Couldn't skip (" + code + ")."; }; var refreshTimer = new System.Windows.Forms.Timer { Interval = 3000 }; refreshTimer.Tick += (s, e) => { if (queuePage.Visible) refreshQueue(false); }; refreshTimer.Start(); form.FormClosed += (s, e) => refreshTimer.Dispose(); // Tabs var nav = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 48, BackColor = PANEL, Padding = new Padding(14, 9, 14, 0), WrapContents = false }; var navQueue = button("Queue", INPUT); var navSettings = button("Settings", INPUT); nav.Controls.Add(navQueue); nav.Controls.Add(navSettings); Action showQueue = showIt => { queuePage.Visible = showIt; root.Visible = !showIt; navQueue.BackColor = showIt ? GREEN : INPUT; navQueue.ForeColor = showIt ? Color.Black : TEXT; navSettings.BackColor = showIt ? INPUT : GREEN; navSettings.ForeColor = showIt ? TEXT : Color.Black; if (showIt) refreshQueue(true); }; navQueue.Click += (s, e) => showQueue(true); navSettings.Click += (s, e) => showQueue(false); // Docking goes back-to-front: the nav, added last, takes the top strip first. form.Controls.Add(queuePage); form.Controls.Add(nav); showQueue(Connected()); if (Connected()) form.Shown += (s, e) => { try { loadDevices(); } catch { } }; return form; } static readonly object listenerLock = new object(); static TcpListener activeListener; bool WithStateLock(int timeoutMs, Action body) { using (var mutex = new Mutex(false, STATE_MUTEX)) { bool owned = false; try { try { owned = mutex.WaitOne(timeoutMs); } catch (AbandonedMutexException) { owned = true; } if (!owned) return false; body(); return true; } finally { if (owned) mutex.ReleaseMutex(); } } } static int IndexOfSeq(JArray queue, long seq) { for (int i = 0; i < queue.Count; i++) if ((long)queue[i]["seq"] == seq) return i; return -1; } static string Duration(long ms) { long sec = ms / 1000; return (sec / 60) + ":" + (sec % 60).ToString("00"); } static string PaidText(JObject e, JObject cfg) { long paid = (long?)e["paid"] ?? 0; if (paid > 0) return paid + " " + cfg["currencyName"]; return string.IsNullOrEmpty((string)e["redemptionId"]) ? "free" : "points"; } // Authorization code flow with PKCE: only the Client ID is needed, no secret. string Authorize(string clientId) { string verifier = RandomString(64); string challenge; using (var sha = SHA256.Create()) challenge = Base64Url(sha.ComputeHash(Encoding.ASCII.GetBytes(verifier))); string state = RandomString(16); TcpListener listener; try { // Pressing Connect again replaces an attempt that is still waiting for the browser. lock (listenerLock) { if (activeListener != null) { try { activeListener.Stop(); } catch { } } listener = new TcpListener(IPAddress.Loopback, CALLBACK_PORT); listener.Start(); activeListener = listener; } } catch (Exception ex) { return "Port " + CALLBACK_PORT + " is busy (" + ex.Message + ")"; } try { OpenUrl("https://accounts.spotify.com/authorize?response_type=code&client_id=" + clientId + "&scope=" + Uri.EscapeDataString(SCOPES) + "&redirect_uri=" + Uri.EscapeDataString(REDIRECT_URI) + "&code_challenge_method=S256&code_challenge=" + challenge + "&state=" + state); DateTime deadline = DateTime.UtcNow.AddMinutes(5); while (DateTime.UtcNow < deadline) { if (activeListener != listener) return "Cancelled"; if (!listener.Pending()) { Thread.Sleep(200); continue; } using (var client = listener.AcceptTcpClient()) { client.ReceiveTimeout = 5000; var stream = client.GetStream(); string requestLine; // Browsers open speculative connections that never send a request; skip those. try { requestLine = new StreamReader(stream, Encoding.ASCII).ReadLine() ?? ""; } catch (IOException) { continue; } var m = Regex.Match(requestLine, @"^GET\s+/callback\?(\S*)\s"); if (!m.Success) { Respond(stream, "404 Not Found", ""); continue; } var q = ParseQuery(m.Groups[1].Value); string error = q.ContainsKey("error") ? q["error"] : null; if (error == null && (!q.ContainsKey("state") || q["state"] != state)) error = "state mismatch"; if (error == null && !q.ContainsKey("code")) error = "no code"; if (error != null) { Respond(stream, "200 OK", Page("Spotify connection failed", "Spotify said: " + WebUtility.HtmlEncode(error) + ". Close this tab and try again from Streamer.bot.")); return "Failed: " + error; } int code; string text = Http("POST", "https://accounts.spotify.com/api/token", "grant_type=authorization_code&code=" + Uri.EscapeDataString(q["code"]) + "&redirect_uri=" + Uri.EscapeDataString(REDIRECT_URI) + "&client_id=" + Uri.EscapeDataString(clientId) + "&code_verifier=" + verifier, null, "application/x-www-form-urlencoded", out code); if (code != 200) { Respond(stream, "200 OK", Page("Spotify connection failed", "Token exchange failed (" + code + "). Check the redirect URI in your Spotify app.")); CPH.LogWarn("[SpotifySR] token exchange failed: " + code + " " + Trunc(text, 300)); return "Failed: token exchange " + code; } SaveTokens(JObject.Parse(text)); var me = Api("GET", "/me", null); string name = me == null ? "" : ((string)me["display_name"] ?? (string)me["id"] ?? ""); CPH.SetGlobalVar(G_ACCOUNT, name, true); Respond(stream, "200 OK", Page("Spotify connected", "Song requests are linked to " + WebUtility.HtmlEncode(name) + ". You can close this tab.")); return "Connected as " + name; } } return "Timed out. Press Connect to try again."; } catch (Exception ex) { CPH.LogError("[SpotifySR] authorize: " + ex); return "Failed: " + ex.Message; } finally { listener.Stop(); lock (listenerLock) { if (activeListener == listener) activeListener = null; } } } static void Respond(Stream stream, string status, string html) { byte[] body = Encoding.UTF8.GetBytes(html); byte[] head = Encoding.ASCII.GetBytes("HTTP/1.1 " + status + "\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " + body.Length + "\r\nConnection: close\r\n\r\n"); stream.Write(head, 0, head.Length); stream.Write(body, 0, body.Length); stream.Flush(); } static string Page(string title, string message) { return "" + title + "

" + title + "

" + message + "

"; } static Dictionary ParseQuery(string q) { var d = new Dictionary(); foreach (var part in q.Split('&')) { int i = part.IndexOf('='); if (i <= 0) continue; d[Uri.UnescapeDataString(part.Substring(0, i))] = Uri.UnescapeDataString(part.Substring(i + 1).Replace('+', ' ')); } return d; } static string RandomString(int length) { const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; var bytes = new byte[length]; using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(bytes); var sb = new StringBuilder(length); foreach (byte b in bytes) sb.Append(chars[b % chars.Length]); return sb.ToString(); } static string Base64Url(byte[] data) { return Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } static void OpenUrl(string url) { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch { } } }