29 Commits
Author SHA1 Message Date
nutty 2a6a0bdc29 Merge branch 'main' of https://github.com/nuttylmao/smtc-bridge 2026-09-01 15:54:20 +10:00
nutty 5f2a103513 Final cleaup - updated to v1.0.0 2026-09-01 15:54:18 +10:00
nutty 91e4e81a6c Update README.md 2026-08-27 11:36:18 +10:00
nutty 058e690408 Added ability to access SMTC Bridge outside localhost 2026-08-27 11:30:00 +10:00
nutty 14b3667baa • Decreased cache time
• Increased album art quality (40->80)
2026-08-16 08:27:41 +10:00
nutty 41fd320e31 Idk why tf I was instantiating a new SMTC Manager on every call. Seriously, what the actual fuck was I thinking. FUCK ME. 2026-08-16 07:35:52 +10:00
nutty 9338926310 Added a "Start with Windows" option 2026-08-16 05:39:12 +10:00
nutty 7a1dc81cdd Merge branch 'main' of https://github.com/nuttylmao/smtc-bridge 2026-08-16 05:33:37 +10:00
nutty 3226e12c3b Reintroduced caching — if it has been less than a 1 second since the last call, just return the same payload 2026-08-16 05:33:34 +10:00
nutty c9b00b12ce Update README.md 2026-08-12 00:42:22 +10:00
nutty e66b937b05 Update README.md 2026-08-12 00:40:57 +10:00
nutty 6523694f17 Updated version number 2026-08-10 19:58:07 +10:00
nutty 1c934f7e1e Adjust Album Artwork compression settings 2026-08-10 19:57:39 +10:00
nutty 578a05f2c1 Album art is no longer served as a base64 string — art is saved locally as a JPG 2026-08-09 23:40:02 +10:00
nutty 031315c591 Disabled Album Art caching as it was causing sync issues 2026-07-23 11:34:41 +10:00
nutty f32d5739e1 Oops 2026-07-01 05:17:02 +10:00
nutty e6558ebbe0 Change overlay URL 2026-07-01 00:16:07 +10:00
nutty 2ca3e220bd Ignore binary folders 2026-06-29 05:40:10 +10:00
nuttylmao 1ea584f6cc Updated icon 2026-06-29 05:29:18 +10:00
nuttylmao 82ff0d5519 Update README.md 2026-06-29 04:57:28 +10:00
nuttylmao a2beea8d75 Update README.md 2026-06-29 04:57:05 +10:00
nuttylmao 4aa2f2be1e Update README.md 2026-06-29 04:56:20 +10:00
nuttylmao 47f42c9f39 Update README.md 2026-06-29 04:55:05 +10:00
nuttylmao afe00cbc95 Update README.md 2026-06-29 04:54:45 +10:00
nuttylmao 0fc7d26001 Update README.md 2026-06-29 04:54:13 +10:00
nuttylmao 6524888c8e Update README.md 2026-06-29 04:54:02 +10:00
nuttylmao 53808bee74 Update README.md 2026-06-29 04:49:55 +10:00
nuttylmao cd17f85f81 Update README.md 2026-06-29 04:44:51 +10:00
nuttylmao 758a63a6a2 Update README.md 2026-06-29 04:40:03 +10:00
6 changed files with 291 additions and 93 deletions
+2
View File
@@ -0,0 +1,2 @@
build/
dist/
+79 -7
View File
@@ -1,10 +1,19 @@
# SMTC Bridge # SMTC Bridge
SMTC Bridge was designed to expose Windows *System Media Transport Controls (SMTC)* as a clean REST API. SMTC Bridge is a lightweight system tray application that exposes Windows ***System Media Transport Controls (SMTC)*** as a clean REST API.
This was designed for developers to easily create their own "Now Playing" widgets that work with anything supported by SMTC, bypassing the complicated API limitations imposed by streaming platforms (fuck you Spotify). This tool allows developers to easily create their own "Now Playing" widgets that work with anything supported by SMTC, bypassing the complex API limitations imposed by streaming platforms (fuck you Spotify).
Simply open run the system tray application, and a local web server will run in the background, accessible at `http://127.0.0.1:5000/now-playing/` Simply run the application, and a local web server will run in the background. By default, it can be accessed here after running the application:<br>
[http://127.0.0.1:5000/now-playing/](http://127.0.0.1:5000/now-playing/)
A ready-to-use "Now Playing" widget utilizing SMTC Bridge is available to try here:<br>
**[https://widgets.nutty.gg/now-playing/settings/](https://widgets.nutty.gg/now-playing/settings/)**
## Quick Start
1. Download the latest `.exe` from the [Releases page](https://github.com/nuttylmao/smtc-bridge/releases).
2. Run the application — a tray icon will appear.
3. Right-click the icon to view the API.
### Endpoints ### Endpoints
@@ -13,10 +22,73 @@ Simply open run the system tray application, and a local web server will run in
| `GET` | `/now-playing` | Returns the current media state as JSON. | | `GET` | `/now-playing` | Returns the current media state as JSON. |
| `GET` | `/sessions` | Returns a list of all active media sessions. | | `GET` | `/sessions` | Returns a list of all active media sessions. |
## Quick Start ### Schema
1. Download the latest `.exe` from the [Releases page](releases). The `/now-playing` endpoint provides a real-time snapshot of your active media sessions. Developers can integrate this into their own web widgets using the following JSON structure:
2. Run the application — a tray icon will appear.
3. Right-click the icon to view the API. ```json
{
"app_version": "string",
"current_session_id": "string",
"sessions": [
{
"source_app_id": "string",
"media_properties": {
"Title": "string",
"Artist": "string",
"AlbumTitle": "string",
"AlbumArtist": "string",
"Thumbnail": "string (base64)",
"AlbumTrackCount": "integer",
"TrackNumber": "integer",
"Genres": "array of strings",
"Subtitle": "string"
},
"playback_info": {
"PlaybackStatus": "integer",
"PlaybackType": "integer",
"PlaybackRate": "number or null",
"IsShuffleActive": "boolean",
"AutoRepeatMode": "integer"
},
"timeline_properties": {
"Position": "integer (ms)",
"StartTime": "integer (ms)",
"EndTime": "integer (ms)",
"MinSeekTime": "integer (ms)",
"MaxSeekTime": "integer (ms)",
"LastUpdatedTime": "string (ISO 8601)"
}
}
]
}
```
### Enums
#### Playback Status
| Value | Status | Description |
| :--- | :--- | :--- |
| `0` | `CLOSED` | Engine uninitialized or empty |
| `1` | `OPENED` | Pipeline loaded but idling |
| `2` | `CHANGING` | Buffering, track skipping, or seeking |
| `3` | `STOPPED` | Track queued but fully stopped |
| `4` | `PLAYING` | Audio actively streaming |
| `5` | `PAUSED` | Audio frozen |
#### Playback Type
| Value | Type | Description |
| :--- | :--- | :--- |
| `0` | `UNKNOWN` | Generic audio wrapper |
| `1` | `MUSIC` | Pure audio pipeline (Spotify, iTunes, etc.) |
| `2` | `VIDEO` | Visual media feed (YouTube, Twitch, etc.) |
| `3` | `IMAGE` | Static slideshow presentation hook |
#### Auto Repeat Mode
| Value | Mode | Description |
| :--- | :--- | :--- |
| `0` | `NONE` | Plays queue through and terminates |
| `1` | `TRACK` | Single active song looping |
| `2` | `LIST` | Parent playlist/album looping |
## Credits ## Credits
Built and maintained by **nutty**. Built and maintained by **nutty**.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 120 KiB

+206 -82
View File
@@ -1,15 +1,43 @@
# Versioning # Versioning
APP_VERSION = "0.0.1" APP_VERSION = "1.0.0"
DEVELOPER = "nutty" DEVELOPER = "nutty"
# IMPORTANT SHIT STARTS HERE ###############
### IMPORTS ###
###############
import sys
import traceback
import os
import datetime import datetime
import traceback
import asyncio
import json
import base64
import threading
import os
import sys
import psutil
import tempfile
import atexit
import pystray
import webbrowser
import configparser
import time
import platform
import socket
import hashlib
from PIL import Image
from flask import Flask, jsonify
from flask_cors import CORS
from winsdk.windows.media.control import GlobalSystemMediaTransportControlsSessionManager as SMTC
from winsdk.windows.storage.streams import DataReader
from plyer import notification
import winsdk._winrt as winrt
from collections import OrderedDict
# IMPORTANT SHIT STARTS HERE
def log_crash(e): def log_crash(e):
# 1. Ensure the 'logs' folder exists # 1. Ensure the 'logs' folder exists
@@ -44,34 +72,6 @@ def log_crash(e):
pass # Ignore errors if file is locked or already gone pass # Ignore errors if file is locked or already gone
try: try:
###############
### IMPORTS ###
###############
import asyncio
import json
import base64
import io
import threading
import os
import sys
import psutil
import tempfile
import atexit
import pystray
import webbrowser
import configparser
from PIL import Image
from flask import Flask, jsonify
from flask_cors import CORS
from winsdk.windows.media.control import GlobalSystemMediaTransportControlsSessionManager as SMTC
from winsdk.windows.storage.streams import DataReader
from plyer import notification
import winsdk._winrt as winrt
############################# #############################
### SINGLE INSTANCE CHECK ### ### SINGLE INSTANCE CHECK ###
############################# #############################
@@ -144,9 +144,25 @@ try:
HOST = settings.get('SERVER', 'Host') HOST = settings.get('SERVER', 'Host')
PORT = settings.getint('SERVER', 'Port') PORT = settings.getint('SERVER', 'Port')
def get_local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
DISPLAY_HOST = get_local_ip() if HOST == "0.0.0.0" else HOST
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
ARTWORK_CACHE = {}
# Create a local temp directory for thumbnails
THUMB_DIR = os.path.join(tempfile.gettempdir(), 'smtc_bridge_thumbs')
if not os.path.exists(THUMB_DIR):
os.makedirs(THUMB_DIR)
@@ -154,30 +170,69 @@ try:
### CORE FUNCTIONS ### ### CORE FUNCTIONS ###
###################### ######################
async def get_all_media_info(): # Cache variables for the SMTC manager and rate limiting
global ARTWORK_CACHE smtc_manager = None
last_execution_time = 0.0
last_payload = None
# Thumbnail cache to avoid reprocessing the same artwork repeatedly
MAX_CACHE_SIZE = 50 # Maximum number of unique thumbnails to cache
thumb_cache = OrderedDict()
async def get_all_media_info():
try: try:
# We will cache the last execution time and payload to avoid redundant parsing if requests flood in faster than 0.5 seconds.
current_time = time.time()
global smtc_manager, last_execution_time, last_payload, thumb_cache
# If requests flood in faster than 0.5 seconds, return the cached result
# to completely spare the CPU from redundant parsing.
if (current_time - last_execution_time) < 0.5 and last_payload:
# print("Using cached media info.")
return last_payload
# else:
# print("Fetching fresh media info.")
last_execution_time = current_time
# Instantiate the SMTC manager -> This allows use to "talk" to the Windows Media API # Instantiate the SMTC manager -> This allows use to "talk" to the Windows Media API
manager = await SMTC.request_async() # Reuse the manager if we already have it, otherwise request it once
if not smtc_manager:
print("Instantiating new SMTC manager...")
smtc_manager = await SMTC.request_async()
manager = smtc_manager
# If it returns null, then no media is playing, or something fucked up and I have no # If it returns null, then no media is playing, or something fucked up and I have no
# idea what to do, so just return an empty session list # idea what to do, so just return an empty session list
if not manager: if not manager:
return {"current_session_id": None, "sessions": []} return {"current_session_id": None, "sessions": []}
# This is the session for the current media player -> Whatever Windows deems is "in focus" # current_focused: This is the session for the current media player -> Whatever Windows deems is "in focus"
# will be the current session. # will be the current session.
# all_sessions: We will also get all sessions, not just the current session.
# This will provide the client with all the necessary info if they want to target just one application.
# We will store the SourceAppUserModelId, which we all add to the final payload. # We will store the SourceAppUserModelId, which we all add to the final payload.
# For all available properties/methods/events, see the official docs: # For all available properties/methods/events, see the official docs:
# https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession?view=winrt-28000 # https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession?view=winrt-28000
current_focused = manager.get_current_session()
# Try to pull sessions using the cached manager
try:
current_focused = manager.get_current_session()
all_sessions = manager.get_sessions()
except Exception:
# If the COM context dropped or invalidated, reset it and retry once
print("Instantiating new SMTC manager...")
smtc_manager = await SMTC.request_async()
manager = smtc_manager
if not manager:
return {"current_session_id": None, "sessions": []}
current_focused = manager.get_current_session()
all_sessions = manager.get_sessions()
current_session_id = current_focused.source_app_user_model_id if current_focused else None current_session_id = current_focused.source_app_user_model_id if current_focused else None
# We will also get all sessions, not just the current session. # We will store all the session info in a list of dictionaries, which we will return as JSON
# This will provide the client with all the necessary info if they want to target just
# one application.
all_sessions = manager.get_sessions()
sessions_list = [] sessions_list = []
# We will not iterate over all the sessions, and grab all the available info # We will not iterate over all the sessions, and grab all the available info
@@ -262,46 +317,48 @@ try:
"Thumbnail": None "Thumbnail": None
} }
# Add the album art (i.e. "Thumbnail") as a Base64 string. # Read thumbnail and convert straight to Base64
# We will cache this so we don't have to read it every single call. thumb_url = None
# Use the track title and artist as the key -> {title} - {artist} if raw_media and raw_media.thumbnail:
# Build the key
title = raw_media.title if raw_media else "Unknown"
artist = raw_media.artist if raw_media else "Unknown"
track_key = f"{title} - {artist}"
# Check if the album art is cached.
# If yes:
# - Retrieve it from the cache.
# - Add it to the payload
if app_id in ARTWORK_CACHE and ARTWORK_CACHE[app_id]["track_key"] == track_key:
media_data["Thumbnail"] = ARTWORK_CACHE[app_id]["base64"]
# If not
# - Read the Base64 string
# - Cache the Base64 string
# - Add it to the payload
elif raw_media and raw_media.thumbnail:
try: try:
# Read the Base64 string
stream_ref = raw_media.thumbnail stream_ref = raw_media.thumbnail
stream = await stream_ref.open_read_async() stream = await stream_ref.open_read_async()
reader = DataReader(stream.get_input_stream_at(0))
await reader.load_async(stream.size)
buffer = bytearray(stream.size)
reader.read_bytes(buffer)
img = Image.open(io.BytesIO(buffer)) if stream.size > 0:
base64_art = f"data:image/png;base64,{base64.b64encode(buffer).decode('utf-8')}" reader = DataReader(stream.get_input_stream_at(0))
await reader.load_async(stream.size)
buffer = bytearray(stream.size)
reader.read_bytes(buffer)
# Cache the Base64 string # Hash the raw bytes to see if the artwork is unique
ARTWORK_CACHE[app_id] = {"track_key": track_key, "base64": base64_art} img_hash = hashlib.md5(buffer).hexdigest()
# Add it to the payload # If we've already processed this exact image bytes, grab it from cache instantly
media_data["Thumbnail"] = base64_art if img_hash in thumb_cache:
except Exception: thumb_cache.move_to_end(img_hash)
pass thumb_url = thumb_cache[img_hash]
# print(f"Using cached artwork for: {media_data['Artist']} - {media_data['Title']}")
else:
print(f"Processing new artwork for: {media_data['Artist']} - {media_data['Title']}")
# Convert raw Windows bytes straight to Base64
encoded_img = base64.b64encode(bytes(buffer)).decode('utf-8')
thumb_url = f"data:image/jpeg;base64,{encoded_img}"
# Store it in the cache so we never process it again for this track
thumb_cache[img_hash] = thumb_url
# If the cache is full, remove the least recently used item
if len(thumb_cache) > MAX_CACHE_SIZE:
thumb_cache.popitem(last=False)
print(f"Removed least recently used artwork from cache. Current cache size: {len(thumb_cache)}")
except Exception as e:
thumb_url = None
# Add the Base64 data string to the payload
media_data["Thumbnail"] = thumb_url
# Finally, assemble all the data into a session object, and add it to the session list # Finally, assemble all the data into a session object, and add it to the session list
sessions_list.append({ sessions_list.append({
@@ -312,20 +369,81 @@ try:
}) })
# Build the final payload # Build the final payload
return { payload = {
"app_version": APP_VERSION, "app_version": APP_VERSION,
"os": f"{platform.system()} {platform.release()}",
"current_session_id": current_session_id, "current_session_id": current_session_id,
"sessions": sessions_list "sessions": sessions_list
} }
# Save to global payload cache
last_payload = payload
return payload
except Exception as e: except Exception as e:
return {"current_session_id": None, "sessions": [], "error": str(e)} return {"current_session_id": None, "sessions": [], "error": str(e)}
########################
### STARTUP SHORTCUT ###
########################
def get_startup_shortcut_path():
# Gets the path to the current user's Startup folder
startup_dir = os.path.join(os.environ['APPDATA'], r'Microsoft\Windows\Start Menu\Programs\Startup')
return os.path.join(startup_dir, 'SMTCBridge.lnk')
def is_start_with_windows():
return os.path.exists(get_startup_shortcut_path())
def set_start_with_windows(enable: bool):
shortcut_path = get_startup_shortcut_path()
if enable:
# Determine the correct path (handles both raw script and PyInstaller .exe)
if getattr(sys, 'frozen', False):
target_path = sys.executable
working_dir = os.path.dirname(sys.executable)
else:
target_path = sys.executable # python.exe
working_dir = os.path.dirname(os.path.abspath(__file__))
# If running as script, you might want to point to the script instead,
# but usually this feature is intended for the built .exe
# Use a quick PowerShell command to create a proper Windows .lnk shortcut
# This avoids needing external libraries like winshell
script_args = f"""
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut('{shortcut_path}')
$Shortcut.TargetPath = '{target_path}'
$Shortcut.WorkingDirectory = '{working_dir}'
$Shortcut.Save()
"""
import subprocess
subprocess.run(["powershell", "-Command", script_args], capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
else:
if os.path.exists(shortcut_path):
try:
os.remove(shortcut_path)
except OSError:
pass
def toggle_startup(icon, item):
current_state = is_start_with_windows()
set_start_with_windows(not current_state)
################# #################
### ENDPOINTS ### ### ENDPOINTS ###
################# #################
@app.route('/artwork/<app_identifier>')
def serve_artwork(app_identifier):
from flask import send_from_directory
# Serve the cached thumbnail directly from the temp directory
return send_from_directory(THUMB_DIR, f"{app_identifier}.jpg")
@app.route('/now-playing') @app.route('/now-playing')
def now_playing(): def now_playing():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@@ -389,12 +507,18 @@ try:
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem(f"SMTC Bridge v{APP_VERSION} by {DEVELOPER}", None, enabled=False), pystray.MenuItem(f"SMTC Bridge v{APP_VERSION} by {DEVELOPER}", None, enabled=False),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("View Data (JSON)", lambda: webbrowser.open(f"http://{HOST}:{PORT}/now-playing")), pystray.MenuItem("View Data (JSON)", lambda: webbrowser.open(f"http://{DISPLAY_HOST}:{PORT}/now-playing")),
pystray.MenuItem("View Active Sessions", lambda: webbrowser.open(f"http://{HOST}:{PORT}/sessions")), pystray.MenuItem("View Active Sessions", lambda: webbrowser.open(f"http://{DISPLAY_HOST}:{PORT}/sessions")),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("★ Customize Overlay", lambda: webbrowser.open(f"https://beta.nutty.gg/now-playing/settings/")), pystray.MenuItem("★ Customize Overlay", lambda: webbrowser.open(f"https://widgets.nutty.gg/now-playing/settings/")),
pystray.MenuItem("★ Try my stream widgets!", lambda: webbrowser.open(f"https://nutty.gg/collections/member-exclusive-widgets")), pystray.MenuItem("★ Try my stream widgets!", lambda: webbrowser.open(f"https://nutty.gg/collections/member-exclusive-widgets")),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem(
"Start with Windows",
toggle_startup,
checked=lambda item: is_start_with_windows()
),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", on_quit) pystray.MenuItem("Quit", on_quit)
) )