From 69a17d5c7e91be80762f341c2859451d593bafda Mon Sep 17 00:00:00 2001 From: nuttylmao <76841561+nuttylmao@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:35:37 +1000 Subject: [PATCH] Initial Release --- build.bat | 30 ++++ launch.bat | 1 + requirements.txt | 7 + settings.ini | 3 + smtc-bridge.ico | Bin 0 -> 4286 bytes smtc-bridge.pyw | 446 +++++++++++++++++++++++++++++++++++++++++++++++ smtc-bridge.spec | 54 ++++++ 7 files changed, 541 insertions(+) create mode 100644 build.bat create mode 100644 launch.bat create mode 100644 requirements.txt create mode 100644 settings.ini create mode 100644 smtc-bridge.ico create mode 100644 smtc-bridge.pyw create mode 100644 smtc-bridge.spec diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..243d3b4 --- /dev/null +++ b/build.bat @@ -0,0 +1,30 @@ +@echo off +setlocal + +echo --- Checking Dependencies --- +:: Ensure pip is installed +python -m ensurepip --default-pip >nul 2>&1 + +:: Install or upgrade requirements +pip install -r requirements.txt + +:: Ensure PyInstaller is installed +pip show pyinstaller >nul 2>&1 +if %errorlevel% neq 0 ( + echo Installing PyInstaller... + pip install pyinstaller +) + +echo. +echo --- Cleaning Old Builds --- +if exist "build" rmdir /s /q "build" +if exist "dist" rmdir /s /q "dist" + +echo. +echo --- Building Executable --- +pyinstaller --noconfirm smtc-bridge.spec + +echo. +echo --- Build Complete! --- +echo Check the 'dist' folder for your smtc-bridge.exe. +pause \ No newline at end of file diff --git a/launch.bat b/launch.bat new file mode 100644 index 0000000..32e3899 --- /dev/null +++ b/launch.bat @@ -0,0 +1 @@ +python .\smtc-bridge.pyw \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..225e0dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask +flask-cors +psutil +pystray +Pillow +plyer +winsdk \ No newline at end of file diff --git a/settings.ini b/settings.ini new file mode 100644 index 0000000..0e2d544 --- /dev/null +++ b/settings.ini @@ -0,0 +1,3 @@ +[SERVER] +host = 127.0.0.1 +port = 5000 \ No newline at end of file diff --git a/smtc-bridge.ico b/smtc-bridge.ico new file mode 100644 index 0000000000000000000000000000000000000000..b44df1bde85eee3f79314d34f318dc45c9803bbe GIT binary patch literal 4286 zcmeH}--}FP7{||8n#FD{7fNY&{kTvY`3tO+8!aweEEg_ZXcH}p3q_5nRg~Nae}J~I zD55CihH^nswnPo9(6T8%){o`*d}hyEr)g$vX5?lbedc}7^FGh>J?A;^dsY->_}AGP z`5O(kMbW${ik1SdXcg!$8f(L?lT11qzl{zwI`E%6(1Lz19D}XEDg9?=Z8g+VQ|;q0 z4l(?IbI=a;Hfvg24Ykx%`$KZAFL00f8fb!A%^KFSW~d)Vwa3c%U4IWJp$q=zKFl%K z8rBM$=~3;&@TJ1Ku_JIA*1~+44wcL`V~jP&+_YYLuBO_}aH2|F$nkv*!$lZ?esC{| zbA7HEV{FK)G};%nwQXRWc|VhRrMz1F)(*Yi&OBJqKa0b&@eXRyDVIB%)IOEeE~kHx zHEkFC0?&$Lxq4IanLClt$EfWGbIa);G63rZW6r}~xCL(@oZV{L#=K5)_Ay=q``!bZ z&seIz8J{svU^RGcjNb=s7WiM9o$v{~mm#>CaGB9F=6UuxjWtK}9N35Tuo508*IO8a z&0*qu`37&{4y5(DESl$e4D!$dGZNYu?}N|17}BP`wQhjx-{B0*g|vCUKHp0)UhLg6 zXa+6l-)26u=$ZU!Whm7*$K0C`>iIqJtj>mVmch9kh9KmOGQSQ7U=MtRyU-5yO%}mJ zus1uvSaZw`buKVp0c!lo+<(3^_rn7i2kYo_^!h10N^t%aN@KjIXW*XffvH*-fmdJy z_z)_ndolT}d(TcO&#cy|hXs8kda@G&)f44#ZU~8^?vFIL=2gpVjUOPIjRv npW_=YNBK5NE| 10: + oldest_file = files.pop(0) + try: + os.remove(oldest_file) + except OSError: + pass # Ignore errors if file is locked or already gone + +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 ### + ############################# + + def is_already_running(): + # Use the system temp directory + lockfile = os.path.join(tempfile.gettempdir(), 'smtc_bridge.lock') + + # Check if the lockfile already exists + if os.path.exists(lockfile): + try: + with open(lockfile, 'r') as f: + pid = int(f.read()) + # Check if that PID is actually still running + if psutil.pid_exists(pid): + return True # A real instance is running + else: + os.remove(lockfile) # Stale lock from a crash, clean it up + except (ValueError, PermissionError): + # If the file is garbled or we can't read it, treat it as a stale lock + os.remove(lockfile) + + # No instance found, write our PID to the lockfile + try: + with open(lockfile, 'w') as f: + f.write(str(os.getpid())) + except IOError: + pass # Could not write lockfile, but let's proceed anyway + + # Register a cleanup function to delete the file when the app closes + atexit.register(lambda: os.remove(lockfile) if os.path.exists(lockfile) else None) + + return False + + if is_already_running(): + print("Another instance is already running. Exiting...") + sys.exit() + + + + ###################### + ### INITIALIZATION ### + ###################### + + def get_resource_path(relative_path): + """ Get absolute path to resource, works for dev and for PyInstaller """ + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + def load_settings(): + config = configparser.ConfigParser() + config['SERVER'] = {'Host': '127.0.0.1', 'Port': '5000'} + + # Ensure settings.ini is looked for in the executable's directory + exe_dir = os.path.dirname(sys.executable if getattr(sys, 'frozen', False) else __file__) + ini_path = os.path.join(exe_dir, 'settings.ini') + + if os.path.exists(ini_path): + config.read(ini_path) + else: + with open(ini_path, 'w') as f: + config.write(f) + return config + + settings = load_settings() + HOST = settings.get('SERVER', 'Host') + PORT = settings.getint('SERVER', 'Port') + + app = Flask(__name__) + CORS(app) + ARTWORK_CACHE = {} + + + + ###################### + ### CORE FUNCTIONS ### + ###################### + + async def get_all_media_info(): + global ARTWORK_CACHE + + try: + # Instantiate the SMTC manager -> This allows use to "talk" to the Windows Media API + manager = await SMTC.request_async() + + # 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" + # will be the current session. + # 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 + current_focused = manager.get_current_session() + 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() + sessions_list = [] + + # We will not iterate over all the sessions, and grab all the available info + for session in all_sessions: + # Get all the available info for each session object: + # 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 + app_id = session.source_app_user_model_id + raw_playback = session.get_playback_info() + raw_timeline = session.get_timeline_properties() + raw_media = await session.try_get_media_properties_async() + + # Playback info + # https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionplaybackinfo?view=winrt-28000 + playback_data = { + # AutoRepeatMode: Specifies the repeat mode of the session. + "AutoRepeatMode": raw_playback.auto_repeat_mode.value if (raw_playback and raw_playback.auto_repeat_mode) else 0, + + # IsShuffleActive: Specifies whether the session is currently playing content in a shuffled order. + "IsShuffleActive": raw_playback.is_shuffle_active if raw_playback else False, + + # PlaybackRate: The rate at which playback is happening (e.g., 1.0 is normal speed). + "PlaybackRate": raw_playback.playback_rate if raw_playback else 1.0, + + # PlaybackStatus: The current playback state of the session (e.g., Playing, Paused). + "PlaybackStatus": raw_playback.playback_status.value if (raw_playback and raw_playback.playback_status) else 0, + + # PlaybackType: Specifies what type of content the session has (e.g., Music, Video). + "PlaybackType": raw_playback.playback_type.value if (raw_playback and raw_playback.playback_type) else 0 + } + + # Timeline properties + # https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessiontimelineproperties?view=winrt-28000 + timeline_data = { + # EndTime: The end timestamp of the current media item. + "EndTime": int(raw_timeline.end_time.total_seconds() * 1000) if raw_timeline.end_time else 0, + + # LastUpdatedTime: The UTC time at which the timeline properties were last updated. + "LastUpdatedTime": str(raw_timeline.last_updated_time) if raw_timeline.last_updated_time else None, + + # MaxSeekTime: The furthest timestamp at which the content can currently seek to. + "MaxSeekTime": int(raw_timeline.max_seek_time.total_seconds() * 1000) if raw_timeline.max_seek_time else 0, + + # MinSeekTime: The earliest timestamp at which the current media item can currently seek to. + "MinSeekTime": int(raw_timeline.min_seek_time.total_seconds() * 1000) if raw_timeline.min_seek_time else 0, + + # Position: The playback position, current as of LastUpdatedTime. + "Position": int(raw_timeline.position.total_seconds() * 1000) if raw_timeline.position else 0, + + # StartTime: The starting timestamp of the current media item. + "StartTime": int(raw_timeline.start_time.total_seconds() * 1000) if raw_timeline.start_time else 0, + } + + # Media properties + # https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmediaproperties?view=winrt-28000 + media_data = { + # Title: The title of the track. + "Title": raw_media.title if raw_media else "Unknown", + + # Artist: The name of the artist. + "Artist": raw_media.artist if raw_media else "Unknown", + + # AlbumTitle: The title of the album. + "AlbumTitle": raw_media.album_title if raw_media else "Unknown", + + # AlbumArtist: The artist associated with the album. + "AlbumArtist": raw_media.album_artist if raw_media else "Unknown", + + # TrackNumber: The track number within the album. + "TrackNumber": raw_media.track_number if raw_media else 0, + + # AlbumTrackCount: The total number of tracks on the album. + "AlbumTrackCount": raw_media.album_track_count if raw_media else 0, + + # Genres: The list of genres associated with the track. + "Genres": list(raw_media.genres) if raw_media else [], + + # Subtitle: Any subtitle information (common in podcasts/videos). + "Subtitle": raw_media.subtitle if raw_media else "", + + # Thumbnail: Our cached thumbnail representation. + "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: + 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 + + # Finally, assemble all the data into a session object, and add it to the session list + sessions_list.append({ + "source_app_id": app_id, + "playback_info": playback_data, + "timeline_properties": timeline_data, + "media_properties": media_data + }) + + # Build the final payload + return { + "app_version": APP_VERSION, + "current_session_id": current_session_id, + "sessions": sessions_list + } + except Exception as e: + return {"current_session_id": None, "sessions": [], "error": str(e)} + + + + ################# + ### ENDPOINTS ### + ################# + + @app.route('/now-playing') + def now_playing(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return jsonify(loop.run_until_complete(get_all_media_info())) + finally: + loop.close() + + @app.route('/sessions', methods=['GET']) + def get_sessions(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def fetch(): + manager = await SMTC.request_async() + if not manager: return [] + return list(set([s.source_app_user_model_id for s in manager.get_sessions()])) + + try: + sessions = loop.run_until_complete(fetch()) + + # Wrapped in a tag with a dark background and some padding + html_list = """ + +

Active Audio Sources:

+ " + return html_list + + except Exception as e: + return f"Error: {str(e)}" + finally: + loop.close() + + + + ########################### + ### SYSTEM TRAY CONTROL ### + ########################### + + # "Quit" menu item + def on_quit(icon, item): + icon.stop() + os._exit(0) + + # Setup the tray icon + def setup_tray(): + # Set the icon + image = Image.open(get_resource_path("smtc-bridge.ico")) + + # Add the menu items + 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.Menu.SEPARATOR, + pystray.MenuItem("★ Customize Overlay", lambda: webbrowser.open(f"https://beta.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("Quit", on_quit) + ) + + # Put it all together + icon = pystray.Icon("MediaBridge", image, "SMTC Bridge", menu=menu) + icon.run() + + + + ################# + ### KICK OFF! ### + ################# + + def run_flask(): + # Disable flask startup messages + import logging + log = logging.getLogger('werkzeug') + log.setLevel(logging.ERROR) + + # Define a helper to trigger the notification + def send_notification(): + notification.notify( + title=f'SMTC Bridge v{APP_VERSION}', + # Use the PORT variable here + message=f'Server successfully started on port {PORT}', + app_icon=get_resource_path('smtc-bridge.ico'), + timeout=5, + ) + + # Run the notification + send_notification() + + # Start the server + try: + app.run(host=HOST, port=PORT, threaded=True, use_reloader=False) + except Exception as e: + with open("error.txt", "w") as f: + f.write(str(e)) + + if __name__ == '__main__': + flask_thread = threading.Thread(target=run_flask, daemon=True) + flask_thread.start() + + # Setup the Tray + setup_tray() + +except Exception as e: + log_crash(e) + sys.exit(1) \ No newline at end of file diff --git a/smtc-bridge.spec b/smtc-bridge.spec new file mode 100644 index 0000000..3e15267 --- /dev/null +++ b/smtc-bridge.spec @@ -0,0 +1,54 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +# Initialize lists +datas = [('smtc-bridge.ico', '.')] +binaries = [] +hiddenimports = ['winsdk', 'winsdk._winrt'] + +# Collect all necessary files for complex packages +for pkg in ['winsdk', 'plyer']: + tmp_ret = collect_all(pkg) + datas += tmp_ret[0] + binaries += tmp_ret[1] + hiddenimports += tmp_ret[2] + +# Add specific platform backend for plyer to ensure notification support +hiddenimports.append('plyer.platforms.win.notification') + +a = Analysis( + ['smtc-bridge.pyw'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='SMTC-Bridge', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['smtc-bridge.ico'], +) \ No newline at end of file