Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6a0bdc29 | ||
|
|
5f2a103513 | ||
|
|
91e4e81a6c | ||
|
|
058e690408 | ||
|
|
14b3667baa | ||
|
|
41fd320e31 | ||
|
|
9338926310 | ||
|
|
7a1dc81cdd | ||
|
|
3226e12c3b | ||
|
|
c9b00b12ce | ||
|
|
e66b937b05 |
@@ -11,7 +11,7 @@ A ready-to-use "Now Playing" widget utilizing SMTC Bridge is available to try he
|
|||||||
**[https://widgets.nutty.gg/now-playing/settings/](https://widgets.nutty.gg/now-playing/settings/)**
|
**[https://widgets.nutty.gg/now-playing/settings/](https://widgets.nutty.gg/now-playing/settings/)**
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
1. Download the latest `.exe` from the [Releases page](releases).
|
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.
|
2. Run the application — a tray icon will appear.
|
||||||
3. Right-click the icon to view the API.
|
3. Right-click the icon to view the API.
|
||||||
|
|
||||||
|
|||||||
+185
-86
@@ -1,15 +1,43 @@
|
|||||||
# Versioning
|
# Versioning
|
||||||
APP_VERSION = "0.0.4"
|
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,35 +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
|
|
||||||
import time
|
|
||||||
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 ###
|
||||||
#############################
|
#############################
|
||||||
@@ -145,6 +144,18 @@ 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)
|
||||||
|
|
||||||
@@ -159,28 +170,69 @@ try:
|
|||||||
### CORE FUNCTIONS ###
|
### CORE FUNCTIONS ###
|
||||||
######################
|
######################
|
||||||
|
|
||||||
|
# Cache variables for the SMTC manager and rate limiting
|
||||||
|
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():
|
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
|
||||||
@@ -265,61 +317,47 @@ try:
|
|||||||
"Thumbnail": None
|
"Thumbnail": None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read and save thumbnail to a local temp file with byte-hashing cache
|
# Read thumbnail and convert straight to Base64
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
if raw_media and raw_media.thumbnail:
|
if raw_media and raw_media.thumbnail:
|
||||||
try:
|
try:
|
||||||
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)
|
|
||||||
|
|
||||||
# Generate a safe filename based on the app_id
|
if stream.size > 0:
|
||||||
safe_app_id = "".join(c for c in app_id if c.isalnum() or c in ('_', '-'))
|
reader = DataReader(stream.get_input_stream_at(0))
|
||||||
thumb_filename = f"{safe_app_id}.jpg"
|
await reader.load_async(stream.size)
|
||||||
thumb_path = os.path.join(THUMB_DIR, thumb_filename)
|
buffer = bytearray(stream.size)
|
||||||
|
reader.read_bytes(buffer)
|
||||||
|
|
||||||
# Hash the raw image bytes to check if the artwork actually changed
|
# Hash the raw bytes to see if the artwork is unique
|
||||||
import hashlib
|
img_hash = hashlib.md5(buffer).hexdigest()
|
||||||
img_hash = hashlib.md5(buffer).hexdigest()
|
|
||||||
|
|
||||||
# Track hashes in memory to avoid writing to disk if nothing changed
|
# If we've already processed this exact image bytes, grab it from cache instantly
|
||||||
if not hasattr(get_all_media_info, "cache"):
|
if img_hash in thumb_cache:
|
||||||
get_all_media_info.cache = {}
|
thumb_cache.move_to_end(img_hash)
|
||||||
|
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']}")
|
||||||
|
|
||||||
# Check if we already processed this exact artwork for this app
|
# Convert raw Windows bytes straight to Base64
|
||||||
if get_all_media_info.cache.get(safe_app_id) == img_hash and os.path.exists(thumb_path):
|
encoded_img = base64.b64encode(bytes(buffer)).decode('utf-8')
|
||||||
# Artwork hasn't changed, reuse existing file and version timestamp
|
thumb_url = f"data:image/jpeg;base64,{encoded_img}"
|
||||||
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
|
# Store it in the cache so we never process it again for this track
|
||||||
img.save(
|
thumb_cache[img_hash] = thumb_url
|
||||||
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
|
# If the cache is full, remove the least recently used item
|
||||||
get_all_media_info.cache[safe_app_id] = img_hash
|
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)}")
|
||||||
|
|
||||||
# Use file modification time as the version token so browsers cache it aggressively
|
except Exception as e:
|
||||||
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:
|
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
|
|
||||||
# Add the local URL to the payload
|
# Add the Base64 data string to the payload
|
||||||
|
|
||||||
media_data["Thumbnail"] = thumb_url
|
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
|
||||||
@@ -331,16 +369,71 @@ 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 ###
|
||||||
#################
|
#################
|
||||||
@@ -414,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://widgets.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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user