Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b3667baa | ||
|
|
41fd320e31 | ||
|
|
9338926310 | ||
|
|
7a1dc81cdd | ||
|
|
3226e12c3b | ||
|
|
c9b00b12ce | ||
|
|
e66b937b05 | ||
|
|
6523694f17 | ||
|
|
1c934f7e1e | ||
|
|
578a05f2c1 |
@@ -8,7 +8,7 @@ Simply run the application, and a local web server will run in the background. B
|
||||
[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/)**
|
||||
**[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).
|
||||
|
||||
+167
-62
@@ -1,5 +1,5 @@
|
||||
# Versioning
|
||||
APP_VERSION = "0.0.2"
|
||||
APP_VERSION = "0.0.5"
|
||||
DEVELOPER = "nutty"
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ try:
|
||||
import pystray
|
||||
import webbrowser
|
||||
import configparser
|
||||
import time
|
||||
import platform
|
||||
from PIL import Image
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
@@ -146,7 +148,11 @@ try:
|
||||
|
||||
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 +160,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
|
||||
album_art_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, album_art_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,67 +304,63 @@ try:
|
||||
"Thumbnail": None
|
||||
}
|
||||
|
||||
# CACHE THE THUMBNAIL, DON'T ENCODE IT EVERY TIME, IT'S SLOW AS FUCK
|
||||
# # 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:
|
||||
# try:
|
||||
# # Read the Base64 string
|
||||
# stream_ref = raw_media.thumbnail
|
||||
# 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))
|
||||
# base64_art = f"data:image/png;base64,{base64.b64encode(buffer).decode('utf-8')}"
|
||||
|
||||
# # Cache the Base64 string
|
||||
# ARTWORK_CACHE[app_id] = {"track_key": track_key, "base64": base64_art}
|
||||
|
||||
# # Add it to the payload
|
||||
# media_data["Thumbnail"] = base64_art
|
||||
# except Exception:
|
||||
# pass
|
||||
|
||||
# Read the Base64 string
|
||||
base64_art = None
|
||||
# Read and save thumbnail to a local temp file with byte-hashing cache
|
||||
thumb_url = None
|
||||
if raw_media and raw_media.thumbnail:
|
||||
try:
|
||||
stream_ref = raw_media.thumbnail
|
||||
stream = await stream_ref.open_read_async()
|
||||
|
||||
# Only proceed if there's actual data to read
|
||||
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')}"
|
||||
except Exception:
|
||||
# Fallback if the stream fails to open or read
|
||||
base64_art = None
|
||||
# Generate a safe filename based on the app_id
|
||||
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)
|
||||
|
||||
# Add it to the payload
|
||||
media_data["Thumbnail"] = base64_art
|
||||
# Hash the raw image bytes to check if the artwork actually changed
|
||||
import hashlib
|
||||
img_hash = hashlib.md5(buffer).hexdigest()
|
||||
|
||||
# Check if we already processed this exact artwork for this app
|
||||
if album_art_cache.get(safe_app_id) == img_hash and os.path.exists(thumb_path):
|
||||
# Artwork hasn't changed, reuse existing file and version timestamp
|
||||
# print("Using cached artwork.")
|
||||
pass
|
||||
else:
|
||||
print(f"Processing new artwork for: {media_data['Artist']} - {media_data['Title']}")
|
||||
# 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=80, # 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
|
||||
album_art_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:
|
||||
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
|
||||
sessions_list.append({
|
||||
@@ -333,20 +371,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()
|
||||
@@ -416,6 +515,12 @@ try:
|
||||
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