18 Commits
Author SHA1 Message Date
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 141 additions and 42 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](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

+60 -35
View File
@@ -1,5 +1,5 @@
# Versioning # Versioning
APP_VERSION = "0.0.1" APP_VERSION = "0.0.4"
DEVELOPER = "nutty" DEVELOPER = "nutty"
@@ -62,6 +62,7 @@ try:
import pystray import pystray
import webbrowser import webbrowser
import configparser import configparser
import time
from PIL import Image from PIL import Image
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_cors import CORS from flask_cors import CORS
@@ -146,7 +147,11 @@ try:
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,9 +159,7 @@ try:
### CORE FUNCTIONS ### ### CORE FUNCTIONS ###
###################### ######################
async def get_all_media_info(): async def get_all_media_info():
global ARTWORK_CACHE
try: try:
# 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() manager = await SMTC.request_async()
@@ -262,29 +265,10 @@ try:
"Thumbnail": None "Thumbnail": None
} }
# Add the album art (i.e. "Thumbnail") as a Base64 string. # Read and save thumbnail to a local temp file with byte-hashing cache
# 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)) reader = DataReader(stream.get_input_stream_at(0))
@@ -292,16 +276,51 @@ try:
buffer = bytearray(stream.size) buffer = bytearray(stream.size)
reader.read_bytes(buffer) reader.read_bytes(buffer)
img = Image.open(io.BytesIO(buffer)) # Generate a safe filename based on the app_id
base64_art = f"data:image/png;base64,{base64.b64encode(buffer).decode('utf-8')}" safe_app_id = "".join(c for c in app_id if c.isalnum() or c in ('_', '-'))
thumb_filename = f"{safe_app_id}.jpg"
thumb_path = os.path.join(THUMB_DIR, thumb_filename)
# Cache the Base64 string # Hash the raw image bytes to check if the artwork actually changed
ARTWORK_CACHE[app_id] = {"track_key": track_key, "base64": base64_art} import hashlib
img_hash = hashlib.md5(buffer).hexdigest()
# Add it to the payload # Track hashes in memory to avoid writing to disk if nothing changed
media_data["Thumbnail"] = base64_art if not hasattr(get_all_media_info, "cache"):
get_all_media_info.cache = {}
# Check if we already processed this exact artwork for this app
if get_all_media_info.cache.get(safe_app_id) == img_hash and os.path.exists(thumb_path):
# Artwork hasn't changed, reuse existing file and version timestamp
pass
else:
# Process and save with Pillow only when bytes actually change
img = Image.open(io.BytesIO(buffer))
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Save at full resolution, but use faster compression settings
img.save(
thumb_path,
"JPEG",
quality=40, # Sweet spot for small size / high visual fidelity
subsampling=2, # Faster compression algorithm for low-end CPUs
optimize=False # Skips the extra CPU pass
)
# Update cache hash
get_all_media_info.cache[safe_app_id] = img_hash
# Use file modification time as the version token so browsers cache it aggressively
thumb_version = int(os.path.getmtime(thumb_path) * 1000)
# Construct the URL with the version query parameter
thumb_url = f"http://{HOST}:{PORT}/artwork/{safe_app_id}?v={thumb_version}"
except Exception: except Exception:
pass thumb_url = None
# Add the local URL 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({
@@ -326,6 +345,12 @@ try:
### 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()
@@ -392,7 +417,7 @@ try:
pystray.MenuItem("View Data (JSON)", lambda: webbrowser.open(f"http://{HOST}:{PORT}/now-playing")), pystray.MenuItem("View Data (JSON)", lambda: webbrowser.open(f"http://{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://{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("Quit", on_quit) pystray.MenuItem("Quit", on_quit)