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 0000000..b44df1b Binary files /dev/null and b/smtc-bridge.ico differ diff --git a/smtc-bridge.pyw b/smtc-bridge.pyw new file mode 100644 index 0000000..d587ab3 --- /dev/null +++ b/smtc-bridge.pyw @@ -0,0 +1,446 @@ +# Versioning +APP_VERSION = "0.0.1" +DEVELOPER = "nutty" + + + +# IMPORTANT SHIT STARTS HERE + +import sys +import traceback +import os +import datetime + +def log_crash(e): + # 1. Ensure the 'logs' folder exists + log_dir = "logs" + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # 2. Create timestamped filename: yyyy-MM-dd hh-mm-ss + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H-%M-%S") + filename = os.path.join(log_dir, f"crash_{timestamp}.txt") + + # 3. Write the error log + with open(filename, "w", encoding="utf-8") as f: + f.write("--- CRASH DETECTED ---\n") + f.write(f"Time: {timestamp}\n") + f.write(str(e) + "\n\n") + f.write(traceback.format_exc()) + + # 4. Keep only the 10 most recent logs + # Get all .txt files in the log directory + files = [os.path.join(log_dir, f) for f in os.listdir(log_dir) if f.endswith(".txt")] + + # Sort by modification time (oldest first) + files.sort(key=os.path.getmtime) + + # If we have more than 10, delete the oldest + while len(files) > 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 = """ + +