Skip to content

V0.1.9#99

Closed
StuxMirai wants to merge 20 commits intoprefrom
v0.1.9
Closed

V0.1.9#99
StuxMirai wants to merge 20 commits intoprefrom
v0.1.9

Conversation

@StuxMirai
Copy link
Owner

@StuxMirai StuxMirai commented Feb 12, 2026

@StuxMirai dont forget to bump version number before finishing

still problems. spotify required registering users with the api application. limited to 5 unless im some multi-million dollar org

https://community.spotify.com/t5/Spotify-for-Developers/Updating-the-Criteria-for-Web-API-Extended-Access/td-p/6920661

…d fields and implement OAuth flow for token retrieval
…eprecated spotify_secret from configuration and update UI for connecting to Spotify.
…nt ID, deriving channel from broadcaster token, and updating settings UI for improved user experience. Refactor connection handling for both Twitch and Spotify accounts.
…hutdown process in BotController. Update Twitch connection handling to specify role (bot/broadcaster) and adjust Spotify redirect URI for consistency.
@StuxMirai StuxMirai changed the base branch from main to pre February 12, 2026 03:54
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • dev
  • -main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces OAuth authentication refactoring, moving from deprecated OAuth patterns to PKCE-based Spotify authentication and Twitch implicit grant flow. It adds Channel Points integration via Twitch EventSub, updates UI components to support new OAuth connection workflows, removes Discord webhook models, and updates the CI/CD pipeline alongside a new local build script. Version bumped to 0.1.9_4.

Changes

Cohort / File(s) Summary
CI/CD and Build
.github/workflows/build-and-sign.yml, build-local.ps1, VERSION
Replaced generic Build step with context-specific builds (PR-to-pre with console, main push without); introduced new PowerShell build script with Nuitka, uv virtual environment, and optional NSIS installer; bumped version to 0.1.9_4.
OAuth and Authentication Refactoring
bot/scrypt_tunes.py, ui/controllers/settings_controller.py, constants.py, ui/models/config.py
Replaced SpotifyOAuth with SpotifyPKCE; added Twitch implicit grant OAuth flow and Channel Points EventSub integration; introduced connect_to_twitch and connect_spotify methods; added TWITCH_CLIENT_ID and DEFAULT_SPOTIFY_CLIENT_ID constants; removed client_secret and spotify_secret from Config; added broadcaster_token field and updated spotify_redirect_uri default.
UI Components for OAuth Workflows
ui/frames/button_setting_row.py, ui/frames/checkbox_setting_row.py, ui/frames/text_setting_row.py, ui/frames/general_settings_frame.py
Introduced ButtonSettingRow component for connection buttons; added text wrapping and justification to label rows; refactored general settings with button-based OAuth connection rows (Twitch bot/broadcaster, Spotify) and Channel Points reward field; added connection handler methods and callbacks.
Bot Controller and Webhook Cleanup
ui/controllers/bot_controller.py, bot/models/discord.py
Added _bot and bot_task attributes to BotController for task lifecycle management; implemented graceful EventSub cleanup with timeout-based shutdown; deleted discord.py file containing Pydantic models and webhook utilities.
Documentation and Setup
README.md, .gitignore
Streamlined README with condensed install and configuration sections; added Channel Points integration section; updated dev announcements; added .cursor/ directory to .gitignore.
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'V0.1.9' is vague and generic, using only a version number without describing the primary changes in the pull request. Replace with a descriptive title that highlights the main change, such as 'Implement OAuth/PKCE authentication and refactor Twitch-Spotify integration' or 'Add Twitch broadcaster token support and Spotify PKCE authentication'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description mentions version bumping reminder and Spotify API limitations, which relate to the changeset's Spotify integration updates and version bump, though it lacks detail about the main OAuth/PKCE refactoring.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch v0.1.9

Comment @coderabbitai help to get the list of available commands and usage tips.

@StuxMirai StuxMirai self-assigned this Feb 12, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/build-and-sign.yml (1)

36-55: ⚠️ Potential issue | 🟠 Major

Pin Nuitka action and version to stable releases instead of @main.

Lines 38 and 52 use Nuitka/Nuitka-Action@main, and lines 40 and 54 specify nuitka-version: main. These unpinned targets introduce supply-chain drift and make builds non-reproducible. Pin to stable releases: Nuitka/Nuitka-Action@v1.4 and nuitka-version: 4.0.

🔧 Proposed fix
      - name: Build (PR to pre with console)
        if: github.event_name == 'pull_request' && github.base_ref == 'pre'
-       uses: Nuitka/Nuitka-Action@main
+       uses: Nuitka/Nuitka-Action@v1.4
         with:
-          nuitka-version: main
+          nuitka-version: 4.0
           script-name: main.py
           disable-console: false
           enable-plugins: tk-inter
           windows-icon-from-ico: icon.ico
           output-dir: build
           output-file: ScryptTunes.exe
           include-data-files: icon.ico=icon.ico
           mode: standalone

       - name: Build (main push, console disabled)
         if: github.ref == 'refs/heads/main'
-       uses: Nuitka/Nuitka-Action@main
+       uses: Nuitka/Nuitka-Action@v1.4
         with:
-          nuitka-version: main
+          nuitka-version: 4.0
           script-name: main.py
🤖 Fix all issues with AI agents
In `@bot/scrypt_tunes.py`:
- Around line 542-545: The ctx.send() call inside the mobile-link branch (the
block starting with "if '.link/' in song_uri:") is missing an await and should
be awaited like the other sends; update the call to await ctx.send(...) so the
coroutine is executed (keep the same message text and surrounding logic where
req.get(...) follows).
- Around line 283-446: The code is doing blocking HTTP calls with req.get/post
inside async functions; modify the three locations (in
_ensure_channel_points_listener where req.get(...) is used to validate the
token, in _start_eventsub_channel_points nested _run() where req.post(...)
creates the EventSub subscription, and in chat_song_request where req.get(...)
is used) to offload the synchronous requests to a threadpool by wrapping them
with await asyncio.to_thread(...) (i.e., replace direct calls to req.get/post
with await asyncio.to_thread(req.get, <args...>) or await
asyncio.to_thread(req.post, <args...>)), preserving the same
headers/json/timeout handling and response checks so the logic using
resp.status_code/resp.json()/r.text continues to work; update imports if needed
(ensure asyncio is imported) and keep the surrounding async function structure
(_ensure_channel_points_listener, _start_eventsub_channel_points::_run,
chat_song_request) unchanged.

In `@README.md`:
- Around line 7-8: The "Dev Announcements" heading currently uses H3 (### Dev
Announcements) which breaks the heading hierarchy; update that heading to H2 by
changing "### Dev Announcements" to "## Dev Announcements" so the document
follows proper Markdown heading levels.

In `@ui/controllers/settings_controller.py`:
- Around line 255-262: The OAuth success callback is being invoked from the
background thread inside _shutdown which calls
controller_ref._oauth_on_success_cb(f"oauth:{access_token}"), but those
callbacks perform Tkinter UI work and must run on the main thread; change the
invocation to marshal to the Tk main loop by using root.after(0, lambda:
controller_ref._oauth_on_success_cb(f"oauth:{access_token}")) (or equivalent) so
the callback runs on the UI thread instead of directly in _shutdown after
controller_ref._oauth_server_httpd.shutdown().

In `@ui/frames/general_settings_frame.py`:
- Around line 51-56: The label for the Channel Points reward currently shows the
internal key; update the TextSettingRow initialization in
general_settings_frame.py so it supplies a user-friendly label/title (e.g.
"Channel Points Reward" or "Channel Points Reward Title") instead of the raw
setting_name. Locate the TextSettingRow call that sets
setting_name="channel_points_reward" and add or replace the label argument your
UI component supports (e.g. setting_label, label, or title) to present the
readable text while keeping setting_name and setting_description unchanged; if
TextSettingRow lacks a label prop, extend its constructor (and usages) to accept
and render a human-facing label.

Comment on lines +283 to +446
async def _ensure_channel_points_listener(self):
reward_name = (self.config.channel_points_reward or "").strip()
if not reward_name:
return
# Need bot token for chat; broadcaster token for channel points (fallback to bot if same)
if not self._twitch_access_token:
logging.warning("Channel points listener not started: missing Twitch access token")
return

token_for_validate = self._twitch_broadcaster_token or self._twitch_access_token
if not self._twitch_user_id:
resp = req.get(
"https://id.twitch.tv/oauth2/validate",
headers={"Authorization": f"Bearer {token_for_validate}"},
timeout=10,
)
if resp.status_code != 200:
logging.warning(f"Token validate failed ({resp.status_code}): {resp.text}")
return
data = resp.json()
self._twitch_user_id = data.get("user_id")
self._twitch_client_id = data.get("client_id") or self._twitch_client_id
user_login = (data.get("login") or "").lower()
scopes = set(data.get("scopes", []) or [])
logging.info(
"Validated Twitch token for login='%s' user_id=%s with scopes=%s",
user_login,
self._twitch_user_id,
",".join(sorted(scopes)) or "<none>",
)
required = {"channel:read:redemptions"}
if not required.issubset(scopes):
logging.warning("Twitch token missing required scope 'channel:read:redemptions'. Please connect Broadcaster with that scope.")
return
if not self._twitch_user_id:
logging.warning("Could not determine user_id from token validate response")
return

configured_channel = (self.config.channel or "").lower()
if configured_channel and configured_channel != user_login:
logging.error(
"Channel Points require the broadcaster token. Configured channel='%s', but authorized as '%s'. "
"Either Connect Broadcaster in Settings, or change the Watch Channel to '%s'.",
configured_channel,
user_login,
user_login,
)
return

await self._start_eventsub_channel_points()

async def _start_eventsub_channel_points(self):
reward_name = (self.config.channel_points_reward or "").strip()
if not reward_name:
return
if not self._twitch_access_token or not self._twitch_user_id:
return
from constants import TWITCH_CLIENT_ID as DEFAULT_TWITCH_CLIENT_ID
client_id = os.getenv("TWITCH_CLIENT_ID") or getattr(self.config, "client_id", None) or self._twitch_client_id or DEFAULT_TWITCH_CLIENT_ID
if not client_id:
logging.error("EventSub requires a Client ID. Set TWITCH_CLIENT_ID env or add client_id to config.json.")
return

if self._eventsub_task and not self._eventsub_task.done():
return

async def _run():
try:
self._eventsub_session = aiohttp.ClientSession()
ws = await self._eventsub_session.ws_connect("wss://eventsub.wss.twitch.tv/ws")
self._eventsub_ws = ws
first = await ws.receive_json()
meta = first.get("metadata", {})
if meta.get("message_type") != "session_welcome":
logging.error(f"EventSub unexpected first message: {first}")
return
session_id = first.get("payload", {}).get("session", {}).get("id")
if not session_id:
logging.error("EventSub missing session id in welcome")
return
auth_token = self._twitch_broadcaster_token or self._twitch_access_token
headers = {
"Authorization": f"Bearer {auth_token}",
"Client-ID": client_id,
"Content-Type": "application/json",
}
body = {
"type": "channel.channel_points_custom_reward_redemption.add",
"version": "1",
"condition": {"broadcaster_user_id": str(self._twitch_user_id)},
"transport": {"method": "websocket", "session_id": session_id},
}
r = req.post("https://api.twitch.tv/helix/eventsub/subscriptions", headers=headers, json=body, timeout=10)
if r.status_code not in (200, 202):
logging.error("EventSub subscription create failed (status=%s): %s", r.status_code, r.text)
return
logging.info("EventSub subscription created for Channel Points redemptions")

async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = msg.json() if hasattr(msg, "json") else json.loads(msg.data)
except Exception:
try:
data = json.loads(msg.data)
except Exception:
logging.debug(f"EventSub non-JSON: {msg.data}")
continue
mtype = data.get("metadata", {}).get("message_type")
if mtype == "notification":
payload = data.get("payload", {})
sub_type = payload.get("subscription", {}).get("type")
if sub_type == "channel.channel_points_custom_reward_redemption.add":
event = payload.get("event", {})
title = (event.get("reward", {}).get("title") or "").strip().lower()
expected = reward_name.lower()
if title == expected:

try:
data_sp = self.sp.currently_playing()
if data_sp and data_sp.get("item"):
song_artists = data_sp["item"]["artists"]
song_artists_names = [a["name"] for a in song_artists]
min_through = int(data_sp["progress_ms"] / (1000 * 60) % 60)
sec_through = int(data_sp["progress_ms"] / (1000) % 60)
time_through = f"{min_through} mins, {sec_through} secs"
min_total = int(data_sp["item"]["duration_ms"] / (1000 * 60) % 60)
sec_total = int(data_sp["item"]["duration_ms"] / (1000) % 60)
time_total = f"{min_total} mins, {sec_total} secs"
message = (
f"Now Playing - {data_sp['item']['name']} by {', '.join(song_artists_names)} | "
f"Link: {data_sp['item']['external_urls']['spotify']} | {time_through} - {time_total}"
)
channel = self.get_channel(self.config.channel)
if channel:
await channel.send(message)
except Exception:
pass
elif mtype == "session_keepalive":
continue
elif mtype == "session_reconnect":
reconnect_url = data.get("payload", {}).get("session", {}).get("reconnect_url")
logging.warning(f"EventSub reconnect requested: {reconnect_url}")
break
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
except Exception as e:
logging.error(f"EventSub error: {e}")
finally:
try:
if self._eventsub_ws:
await self._eventsub_ws.close()
except Exception:
pass
try:
if self._eventsub_session:
await self._eventsub_session.close()
except Exception:
pass
self._eventsub_ws = None
self._eventsub_session = None
self._eventsub_task = None

self._eventsub_task = self.loop.create_task(_run())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the import statement for 'req' in bot/scrypt_tunes.py
head -50 bot/scrypt_tunes.py | grep -n "import\|req"

Repository: StuxMirai/ScryptTunes

Length of output: 661


🏁 Script executed:

# Verify the exact lines of the HTTP calls mentioned in the review
sed -n '280,450p' bot/scrypt_tunes.py | grep -n "req\." -B 2 -A 2

Repository: StuxMirai/ScryptTunes

Length of output: 771


🏁 Script executed:

# Check if there are any other blocking requests in the file
rg "req\.(get|post|put|delete|patch)" bot/scrypt_tunes.py -n

Repository: StuxMirai/ScryptTunes

Length of output: 271


🏁 Script executed:

# Check the Python version to ensure asyncio.to_thread is available (requires Python 3.9+)
cat pyproject.toml | grep -i python || cat setup.py | grep -i python || cat requirements.txt | head -20

Repository: StuxMirai/ScryptTunes

Length of output: 302


🏁 Script executed:

# Check Python version requirement in repository
find . -type f \( -name "*.toml" -o -name "*.cfg" -o -name ".python-version" -o -name "README*" \) -exec grep -l "python" {} \; 2>/dev/null | head -5

Repository: StuxMirai/ScryptTunes

Length of output: 76


🏁 Script executed:

# Get more context around line 545 to verify if it's in async context
sed -n '540,560p' bot/scrypt_tunes.py

Repository: StuxMirai/ScryptTunes

Length of output: 1150


🏁 Script executed:

# Verify the function signatures for the async functions containing blocking calls
sed -n '283,295p' bot/scrypt_tunes.py && echo "---" && sed -n '320,330p' bot/scrypt_tunes.py

Repository: StuxMirai/ScryptTunes

Length of output: 1242


🏁 Script executed:

# Check Python version requirement in README.md
grep -i "python" README.md | head -10

Repository: StuxMirai/ScryptTunes

Length of output: 226


🏁 Script executed:

# Also check if there are any GitHub Actions or other CI files that specify Python version
find . -type f \( -name "*.yml" -o -name "*.yaml" \) -exec grep -l "python" {} \; 2>/dev/null | head -3

Repository: StuxMirai/ScryptTunes

Length of output: 103


🏁 Script executed:

# Check if asyncio.to_thread is already used elsewhere in the codebase
rg "asyncio\.to_thread" bot/ --type py

Repository: StuxMirai/ScryptTunes

Length of output: 47


🏁 Script executed:

# Verify the context of line 545 - check the function it's in
sed -n '520,550p' bot/scrypt_tunes.py | head -40

Repository: StuxMirai/ScryptTunes

Length of output: 1796


🏁 Script executed:

# Check GitHub Actions for Python version
cat ./.github/workflows/build-and-sign.yml

Repository: StuxMirai/ScryptTunes

Length of output: 3429


🏁 Script executed:

# Verify the full signature of chat_song_request to confirm it's async
sed -n '470,475p' bot/scrypt_tunes.py

Repository: StuxMirai/ScryptTunes

Length of output: 282


🏁 Script executed:

# Double-check if there are any try/except blocks or threading already used to handle blocking calls
rg "ThreadPoolExecutor|Thread\(|to_thread" bot/scrypt_tunes.py

Repository: StuxMirai/ScryptTunes

Length of output: 47


Blocking synchronous HTTP calls in async functions will block the event loop.

Three requests.get/post calls are blocking the event loop in async functions:

  • Line 294: req.get() in _ensure_channel_points_listener()
  • Line 375: req.post() in nested _run() within _start_eventsub_channel_points()
  • Line 545: req.get() in chat_song_request()

This blocks chat handling and EventSub processing. Use asyncio.to_thread() to offload these to a thread pool (Python 3.12 supports this):

-            resp = req.get(
-                "https://id.twitch.tv/oauth2/validate",
-                headers={"Authorization": f"Bearer {token_for_validate}"},
-                timeout=10,
-            )
+            resp = await asyncio.to_thread(
+                req.get,
+                "https://id.twitch.tv/oauth2/validate",
+                headers={"Authorization": f"Bearer {token_for_validate}"},
+                timeout=10,
+            )

Apply the same pattern to the other two blocking calls.

🧰 Tools
🪛 Ruff (0.15.0)

[warning] 385-385: Do not catch blind exception: Exception

(BLE001)


[warning] 388-388: Do not catch blind exception: Exception

(BLE001)


[error] 419-420: try-except-pass detected, consider logging the exception

(S110)


[warning] 419-419: Do not catch blind exception: Exception

(BLE001)


[warning] 429-429: Do not catch blind exception: Exception

(BLE001)


[warning] 430-430: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


[error] 435-436: try-except-pass detected, consider logging the exception

(S110)


[warning] 435-435: Do not catch blind exception: Exception

(BLE001)


[error] 440-441: try-except-pass detected, consider logging the exception

(S110)


[warning] 440-440: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@bot/scrypt_tunes.py` around lines 283 - 446, The code is doing blocking HTTP
calls with req.get/post inside async functions; modify the three locations (in
_ensure_channel_points_listener where req.get(...) is used to validate the
token, in _start_eventsub_channel_points nested _run() where req.post(...)
creates the EventSub subscription, and in chat_song_request where req.get(...)
is used) to offload the synchronous requests to a threadpool by wrapping them
with await asyncio.to_thread(...) (i.e., replace direct calls to req.get/post
with await asyncio.to_thread(req.get, <args...>) or await
asyncio.to_thread(req.post, <args...>)), preserving the same
headers/json/timeout handling and response checks so the logic using
resp.status_code/resp.json()/r.text continues to work; update imports if needed
(ensure asyncio is imported) and keep the surrounding async function structure
(_ensure_channel_points_listener, _start_eventsub_channel_points::_run,
chat_song_request) unchanged.

Comment on lines +542 to 545
if '.link/' in song_uri:
ctx.send(
f'@{ctx.author.name} Mobile link detected, attempting to get full url.') # todo: verify this is sending?????
f'@{ctx.author.name} Mobile link detected, attempting to get full url.')
req_data = req.get(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, examine the exact code at lines 542-545 and surrounding context
sed -n '535,555p' bot/scrypt_tunes.py | cat -n

Repository: StuxMirai/ScryptTunes

Length of output: 1187


🏁 Script executed:

#!/bin/bash
# Search for all ctx.send calls in the file to understand the pattern
rg -n "ctx\.send" bot/scrypt_tunes.py | head -20

Repository: StuxMirai/ScryptTunes

Length of output: 1656


🏁 Script executed:

#!/bin/bash
# Check if the file uses async/await patterns
rg -n "async def|await" bot/scrypt_tunes.py | head -20

Repository: StuxMirai/ScryptTunes

Length of output: 1490


🏁 Script executed:

#!/bin/bash
# Get the function definition containing line 543
sed -n '500,545p' bot/scrypt_tunes.py | grep -B 20 "ctx.send" | head -30

Repository: StuxMirai/ScryptTunes

Length of output: 1873


🏁 Script executed:

#!/bin/bash
# Get the exact function definition and the problematic ctx.send call
sed -n '520,560p' bot/scrypt_tunes.py | cat -n

Repository: StuxMirai/ScryptTunes

Length of output: 2511


Add await to ctx.send() call.

The ctx.send() call at line 543 is a coroutine but lacks await. Without it, the message will not be sent and a RuntimeWarning will be raised. All other ctx.send() calls in the file use await; this one should too.

Fix
-                        ctx.send(
+                        await ctx.send(
                             f'@{ctx.author.name} Mobile link detected, attempting to get full url.')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if '.link/' in song_uri:
ctx.send(
f'@{ctx.author.name} Mobile link detected, attempting to get full url.') # todo: verify this is sending?????
f'@{ctx.author.name} Mobile link detected, attempting to get full url.')
req_data = req.get(
if '.link/' in song_uri:
await ctx.send(
f'@{ctx.author.name} Mobile link detected, attempting to get full url.')
req_data = req.get(
🧰 Tools
🪛 Ruff (0.15.0)

[error] 545-545: Probable use of requests call without timeout

(S113)

🤖 Prompt for AI Agents
In `@bot/scrypt_tunes.py` around lines 542 - 545, The ctx.send() call inside the
mobile-link branch (the block starting with "if '.link/' in song_uri:") is
missing an await and should be awaited like the other sends; update the call to
await ctx.send(...) so the coroutine is executed (keep the same message text and
surrounding logic where req.get(...) follows).

Comment on lines +7 to +8
### Dev Announcements
- New: Built‑in Twitch OAuth (Implicit Grant) and Channel Points via EventSub WebSocket. No Twitch client secret required. Spotify now uses PKCE (no client secret).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix heading level jump for “Dev Announcements”.

Line 7 jumps from H1 to H3; change it to H2 to satisfy heading hierarchy (MD001).

✏️ Suggested fix
-### Dev Announcements
+## Dev Announcements
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Dev Announcements
- New: Built‑in Twitch OAuth (Implicit Grant) and Channel Points via EventSub WebSocket. No Twitch client secret required. Spotify now uses PKCE (no client secret).
## Dev Announcements
- New: Built‑in Twitch OAuth (Implicit Grant) and Channel Points via EventSub WebSocket. No Twitch client secret required. Spotify now uses PKCE (no client secret).
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 7-7: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
In `@README.md` around lines 7 - 8, The "Dev Announcements" heading currently uses
H3 (### Dev Announcements) which breaks the heading hierarchy; update that
heading to H2 by changing "### Dev Announcements" to "## Dev Announcements" so
the document follows proper Markdown heading levels.

Comment on lines +255 to +262
def _shutdown():
try:
controller_ref._oauth_server_httpd.shutdown()
except Exception:
pass
if controller_ref._oauth_on_success_cb:
controller_ref._oauth_on_success_cb(f"oauth:{access_token}")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file and understand the context around lines 255-262
cat -n ui/controllers/settings_controller.py | sed -n '240,280p'

Repository: StuxMirai/ScryptTunes

Length of output: 1989


🏁 Script executed:

# Search for _oauth_on_success_cb to understand how it's defined and used
rg "_oauth_on_success_cb" -B 3 -A 3

Repository: StuxMirai/ScryptTunes

Length of output: 1875


🏁 Script executed:

# Search for _shutdown context and where it's called from
rg "_shutdown" -B 5 -A 10

Repository: StuxMirai/ScryptTunes

Length of output: 4487


🌐 Web query:

Tkinter thread-safety messagebox widget calls main thread requirements

💡 Result:

Tkinter (including tkinter.messagebox) must be interacted with from the same thread that owns the Tcl/Tk interpreter—normally the main thread running root.mainloop(). If you call messagebox.askyesno(), showinfo(), etc. from a worker thread, it may hang, do nothing, or raise errors like “main thread is not in main loop”, because Tk activity is expected to happen on the interpreter/event-loop thread. [1][2]

What to do instead (recommended pattern):

  • Do work in background threads.
  • Send a “please show a dialog” request to the GUI thread (via queue.Queue), and have the GUI thread call messagebox....
  • Or schedule the dialog from the GUI thread using root.after(...).

This “all tkinter calls on the main thread; worker threads communicate via a queue; GUI polls or is scheduled with after” approach is the commonly recommended safe method. [3][4]

Minimal example (worker → main thread → messagebox):

import tkinter as tk
from tkinter import messagebox
import threading, queue

q = queue.Queue()

def worker():
    # ... do background work ...
    q.put(("done", "Background task finished"))

def poll():
    try:
        kind, text = q.get_nowait()
    except queue.Empty:
        pass
    else:
        if kind == "done":
            messagebox.showinfo("Info", text)  # runs on main thread
    root.after(50, poll)

root = tk.Tk()
tk.Button(root, text="Start", command=lambda: threading.Thread(target=worker, daemon=True).start()).pack()
root.after(50, poll)
root.mainloop()

Notes

  • Even though some _tkinter calls may be internally queued to the interpreter thread, the safe rule remains: keep GUI operations (including dialogs) on the GUI thread, and ensure the event loop is actually running/processing events. [1][3]

Sources

  1. Python docs (tkinter) on threading/event-loop constraints. [1]
  2. Stack Overflow: messageboxes/dialogs can’t be created in a thread other than the one that created Tk. [2]
  3. Python tracker discussion: safest method is all tkinter calls in main thread + queue.Queue. [3]
  4. Stack Overflow: use after instead of timers/threads to trigger GUI actions. [4]

References:
[1] (docs.python.org)
[2] (stackoverflow.com)
[3] (bugs.python.org)
[4] (stackoverflow.com)

Citations:


🏁 Script executed:

# Search for start_oauth_flow calls to see what callback is passed as on_success
rg "start_oauth_flow" -B 2 -A 2

Repository: StuxMirai/ScryptTunes

Length of output: 47


🏁 Script executed:

# Look for method definitions of callbacks that might be passed as on_success
rg "def.*on_success|on_success.*=" -B 3 -A 5

Repository: StuxMirai/ScryptTunes

Length of output: 5165


🏁 Script executed:

# Search for messagebox calls in the codebase to understand UI patterns
rg "messagebox|showinfo|askyesno|showwarning" -B 2 -A 2

Repository: StuxMirai/ScryptTunes

Length of output: 5369


🏁 Script executed:

# Check if controller_ref has a 'root' attribute to validate the proposed fix
rg "self\.root\s*=" ui/controllers/settings_controller.py -B 2 -A 2

Repository: StuxMirai/ScryptTunes

Length of output: 216


🏁 Script executed:

# Also check the class definition to understand the controller structure
head -50 ui/controllers/settings_controller.py

Repository: StuxMirai/ScryptTunes

Length of output: 1733


Marshal OAuth success callbacks to the UI thread.

The callback invoked on line 261 runs on a background thread (spawned at line 263), but the registered callbacks (_on_twitch_connected, _on_broadcaster_connected, _on_spotify_connected) all trigger messagebox.showinfo() or messagebox.showerror() calls, which must execute on the main Tk thread. Calling Tkinter operations from a worker thread can cause hangs, freezes, or errors.

Use root.after() to marshal the callback back to the main event loop:

Proposed fix
                        if controller_ref._oauth_on_success_cb:
-                            controller_ref._oauth_on_success_cb(f"oauth:{access_token}")
+                            controller_ref.root.after(
+                                0, lambda: controller_ref._oauth_on_success_cb(f"oauth:{access_token}")
+                            )
🧰 Tools
🪛 Ruff (0.15.0)

[error] 258-259: try-except-pass detected, consider logging the exception

(S110)


[warning] 258-258: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@ui/controllers/settings_controller.py` around lines 255 - 262, The OAuth
success callback is being invoked from the background thread inside _shutdown
which calls controller_ref._oauth_on_success_cb(f"oauth:{access_token}"), but
those callbacks perform Tkinter UI work and must run on the main thread; change
the invocation to marshal to the Tk main loop by using root.after(0, lambda:
controller_ref._oauth_on_success_cb(f"oauth:{access_token}")) (or equivalent) so
the callback runs on the UI thread instead of directly in _shutdown after
controller_ref._oauth_server_httpd.shutdown().

Comment on lines +51 to 56
self.channel_points_reward = TextSettingRow(
self,
setting_name="Token",
setting_description="Twitch OAuth Token",
initial_value=settings_controller.get("token"),
hidden=True,
setting_name="channel_points_reward",
setting_description="(Optional) Title of your Channel Points reward to trigger Now Playing",
initial_value=settings_controller.get("channel_points_reward"),
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a user‑friendly label for the Channel Points reward field.

The label currently shows the internal key; consider a readable title.

💡 Proposed fix
-            setting_name="channel_points_reward",
+            setting_name="Channel Points Reward",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.channel_points_reward = TextSettingRow(
self,
setting_name="Token",
setting_description="Twitch OAuth Token",
initial_value=settings_controller.get("token"),
hidden=True,
setting_name="channel_points_reward",
setting_description="(Optional) Title of your Channel Points reward to trigger Now Playing",
initial_value=settings_controller.get("channel_points_reward"),
)
self.channel_points_reward = TextSettingRow(
self,
setting_name="Channel Points Reward",
setting_description="(Optional) Title of your Channel Points reward to trigger Now Playing",
initial_value=settings_controller.get("channel_points_reward"),
)
🤖 Prompt for AI Agents
In `@ui/frames/general_settings_frame.py` around lines 51 - 56, The label for the
Channel Points reward currently shows the internal key; update the
TextSettingRow initialization in general_settings_frame.py so it supplies a
user-friendly label/title (e.g. "Channel Points Reward" or "Channel Points
Reward Title") instead of the raw setting_name. Locate the TextSettingRow call
that sets setting_name="channel_points_reward" and add or replace the label
argument your UI component supports (e.g. setting_label, label, or title) to
present the readable text while keeping setting_name and setting_description
unchanged; if TextSettingRow lacks a label prop, extend its constructor (and
usages) to accept and render a human-facing label.

StuxMirai and others added 5 commits February 11, 2026 23:07
…ning label with a link for more information. Adjust layout for new elements and ensure settings are saved correctly.
@StuxMirai
Copy link
Owner Author

StuxMirai commented Feb 12, 2026

TODO:

  • uv.lock and pyproject.toml
  • separate build and pre-merge action workflows
  • lock imported workflows to version hash
  • remove twitcio, switch to twitchapi + use implicit grant with websocket eventsub (huge refactor here)
  • re-implement prefix command functionality (command dispatcher that reads messages to look for ! prefix and dispatches to appropriate function)
  • update dependencies to latest possible versions

@StuxMirai
Copy link
Owner Author

closing for now to do the above refactors ^

@StuxMirai StuxMirai closed this Feb 12, 2026
@StuxMirai StuxMirai deleted the v0.1.9 branch February 15, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant