Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
058e690408 | ||
|
|
14b3667baa | ||
|
|
41fd320e31 | ||
|
|
9338926310 | ||
|
|
7a1dc81cdd | ||
|
|
3226e12c3b | ||
|
|
c9b00b12ce | ||
|
|
e66b937b05 | ||
|
|
6523694f17 | ||
|
|
1c934f7e1e | ||
|
|
578a05f2c1 | ||
|
|
031315c591 | ||
|
|
f32d5739e1 | ||
|
|
e6558ebbe0 | ||
|
|
2ca3e220bd | ||
|
|
1ea584f6cc | ||
|
|
82ff0d5519 | ||
|
|
a2beea8d75 | ||
|
|
4aa2f2be1e | ||
|
|
47f42c9f39 | ||
|
|
afe00cbc95 | ||
|
|
0fc7d26001 | ||
|
|
6524888c8e | ||
|
|
53808bee74 | ||
|
|
cd17f85f81 | ||
|
|
758a63a6a2 |
@@ -0,0 +1,2 @@
|
||||
build/
|
||||
dist/
|
||||
@@ -1,10 +1,19 @@
|
||||
# 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>
|
||||
**[IF YOU FOUND THIS PAGE EARLY, DON'T SHARE IT WITH ANYONE YET OR I'LL FUCKING CUM](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
|
||||
|
||||
@@ -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` | `/sessions` | Returns a list of all active media sessions. |
|
||||
|
||||
## 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.
|
||||
### Schema
|
||||
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:
|
||||
|
||||
```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
|
||||
Built and maintained by **nutty**.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 120 KiB |
+164
-44
@@ -1,5 +1,5 @@
|
||||
# Versioning
|
||||
APP_VERSION = "0.0.1"
|
||||
APP_VERSION = "0.0.6"
|
||||
DEVELOPER = "nutty"
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ try:
|
||||
import pystray
|
||||
import webbrowser
|
||||
import configparser
|
||||
import time
|
||||
import platform
|
||||
import socket
|
||||
from PIL import Image
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
@@ -144,9 +147,25 @@ try:
|
||||
HOST = settings.get('SERVER', 'Host')
|
||||
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__)
|
||||
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 +173,66 @@ try:
|
||||
### CORE FUNCTIONS ###
|
||||
######################
|
||||
|
||||
async def get_all_media_info():
|
||||
global ARTWORK_CACHE
|
||||
# Cache variables for the SMTC manager and rate limiting
|
||||
smtc_manager = None
|
||||
last_execution_time = 0.0
|
||||
last_payload = None
|
||||
thumb_cache = {}
|
||||
|
||||
async def get_all_media_info():
|
||||
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
|
||||
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
|
||||
# idea what to do, so just return an empty session list
|
||||
if not manager:
|
||||
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.
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
all_sessions = manager.get_sessions()
|
||||
# We will store all the session info in a list of dictionaries, which we will return as JSON
|
||||
sessions_list = []
|
||||
|
||||
# We will not iterate over all the sessions, and grab all the available info
|
||||
@@ -262,46 +317,44 @@ try:
|
||||
"Thumbnail": None
|
||||
}
|
||||
|
||||
# Add the album art (i.e. "Thumbnail") as a Base64 string.
|
||||
# We will cache this so we don't have to read it every single call.
|
||||
# Use the track title and artist as the key -> {title} - {artist}
|
||||
|
||||
# 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:
|
||||
# Read thumbnail and convert straight to Base64
|
||||
thumb_url = None
|
||||
if raw_media and raw_media.thumbnail:
|
||||
try:
|
||||
# Read the Base64 string
|
||||
stream_ref = raw_media.thumbnail
|
||||
stream = await stream_ref.open_read_async()
|
||||
|
||||
if stream.size > 0:
|
||||
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))
|
||||
base64_art = f"data:image/png;base64,{base64.b64encode(buffer).decode('utf-8')}"
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
# Cache the Base64 string
|
||||
ARTWORK_CACHE[app_id] = {"track_key": track_key, "base64": base64_art}
|
||||
# Hash the raw bytes to see if the artwork is unique
|
||||
img_hash = hashlib.md5(buffer).hexdigest()
|
||||
|
||||
# Add it to the payload
|
||||
media_data["Thumbnail"] = base64_art
|
||||
except Exception:
|
||||
pass
|
||||
# If we've already processed this exact image bytes, grab it from cache instantly
|
||||
if img_hash in thumb_cache:
|
||||
thumb_url = thumb_cache[img_hash]
|
||||
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
|
||||
|
||||
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
|
||||
sessions_list.append({
|
||||
@@ -312,20 +365,81 @@ try:
|
||||
})
|
||||
|
||||
# Build the final payload
|
||||
return {
|
||||
payload = {
|
||||
"app_version": APP_VERSION,
|
||||
"os": f"{platform.system()} {platform.release()}",
|
||||
"current_session_id": current_session_id,
|
||||
"sessions": sessions_list
|
||||
}
|
||||
|
||||
# Save to global payload cache
|
||||
last_payload = payload
|
||||
return payload
|
||||
|
||||
except Exception as 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 ###
|
||||
#################
|
||||
|
||||
@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')
|
||||
def now_playing():
|
||||
loop = asyncio.new_event_loop()
|
||||
@@ -389,12 +503,18 @@ try:
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem(f"SMTC Bridge v{APP_VERSION} by {DEVELOPER}", None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
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 Data (JSON)", lambda: webbrowser.open(f"http://{DISPLAY_HOST}:{PORT}/now-playing")),
|
||||
pystray.MenuItem("View Active Sessions", lambda: webbrowser.open(f"http://{DISPLAY_HOST}:{PORT}/sessions")),
|
||||
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.Menu.SEPARATOR,
|
||||
pystray.MenuItem(
|
||||
"Start with Windows",
|
||||
toggle_startup,
|
||||
checked=lambda item: is_start_with_windows()
|
||||
),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Quit", on_quit)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user