simkl-mps 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. simkl_mps/__init__.py +19 -0
  2. simkl_mps/assets/simkl-mps-128.png +0 -0
  3. simkl_mps/assets/simkl-mps-16.png +0 -0
  4. simkl_mps/assets/simkl-mps-24.png +0 -0
  5. simkl_mps/assets/simkl-mps-256.png +0 -0
  6. simkl_mps/assets/simkl-mps-32.png +0 -0
  7. simkl_mps/assets/simkl-mps-48.png +0 -0
  8. simkl_mps/assets/simkl-mps-64.png +0 -0
  9. simkl_mps/assets/simkl-mps-error.ico +0 -0
  10. simkl_mps/assets/simkl-mps-error.png +0 -0
  11. simkl_mps/assets/simkl-mps-paused.ico +0 -0
  12. simkl_mps/assets/simkl-mps-paused.png +0 -0
  13. simkl_mps/assets/simkl-mps-running.ico +0 -0
  14. simkl_mps/assets/simkl-mps-running.png +0 -0
  15. simkl_mps/assets/simkl-mps-stopped.ico +0 -0
  16. simkl_mps/assets/simkl-mps-stopped.png +0 -0
  17. simkl_mps/assets/simkl-mps.ico +0 -0
  18. simkl_mps/assets/simkl-mps.png +0 -0
  19. simkl_mps/backlog_cleaner.py +100 -0
  20. simkl_mps/cli.py +482 -0
  21. simkl_mps/compatibility_patches.py +16 -0
  22. simkl_mps/credentials.py +111 -0
  23. simkl_mps/main.py +296 -0
  24. simkl_mps/media_cache.py +54 -0
  25. simkl_mps/media_tracker.py +122 -0
  26. simkl_mps/monitor.py +141 -0
  27. simkl_mps/movie_scrobbler.py +689 -0
  28. simkl_mps/players/__init__.py +17 -0
  29. simkl_mps/players/mpc.py +188 -0
  30. simkl_mps/players/mpv.py +602 -0
  31. simkl_mps/players/potplayer.py +300 -0
  32. simkl_mps/players/vlc.py +261 -0
  33. simkl_mps/simkl_api.py +412 -0
  34. simkl_mps/tray_app.py +903 -0
  35. simkl_mps/utils/__init__.py +5 -0
  36. simkl_mps/utils/constants.py +9 -0
  37. simkl_mps/utils/create_icns.py +174 -0
  38. simkl_mps/utils/updater.ps1 +467 -0
  39. simkl_mps/utils/updater.sh +368 -0
  40. simkl_mps/window_detection.py +570 -0
  41. simkl_mps-2.0.0.dist-info/LICENSE +674 -0
  42. simkl_mps-2.0.0.dist-info/METADATA +153 -0
  43. simkl_mps-2.0.0.dist-info/RECORD +45 -0
  44. simkl_mps-2.0.0.dist-info/WHEEL +4 -0
  45. simkl_mps-2.0.0.dist-info/entry_points.txt +3 -0
simkl_mps/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ Media Player Scrobbler for SIMKL package.
3
+ """
4
+
5
+ __version__ = "2.0.0"
6
+ __author__ = "kavinthangavel"
7
+
8
+ from simkl_mps.compatibility_patches import apply_patches
9
+ apply_patches()
10
+
11
+ from simkl_mps.main import SimklScrobbler, run_as_background_service, main
12
+ from simkl_mps.tray_app import run_tray_app
13
+ __all__ = [
14
+ 'SimklScrobbler',
15
+ 'run_as_background_service',
16
+ 'main',
17
+ 'run_tray_app',
18
+ 'run_service'
19
+ ]
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,100 @@
1
+ """
2
+ Backlog cleaner module for Media Player Scrobbler for SIMKL.
3
+ Handles tracking of watched movies to sync when connection is restored.
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import logging
9
+ import pathlib
10
+ from datetime import datetime
11
+
12
+ # Configure module logging
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class BacklogCleaner:
16
+ """Manages a backlog of watched movies to sync when connection is restored"""
17
+
18
+ def __init__(self, app_data_dir: pathlib.Path, backlog_file="backlog.json", threshold_days=None):
19
+ self.app_data_dir = app_data_dir
20
+ self.backlog_file = self.app_data_dir / backlog_file # Use app_data_dir
21
+ self.backlog = self._load_backlog()
22
+ self.threshold_days = threshold_days # New parameter for old entries threshold
23
+
24
+ def _load_backlog(self):
25
+ """Load the backlog from file, creating the file if it does not exist."""
26
+ if not os.path.exists(self.app_data_dir):
27
+ try:
28
+ os.makedirs(self.app_data_dir, exist_ok=True)
29
+ logger.info(f"Created app data directory: {self.app_data_dir}")
30
+ except Exception as e:
31
+ logger.error(f"Failed to create app data directory: {e}")
32
+ return []
33
+ if os.path.exists(self.backlog_file):
34
+ try:
35
+ with open(self.backlog_file, 'r', encoding='utf-8') as f:
36
+ content = f.read().strip()
37
+ if content:
38
+ f.seek(0)
39
+ return json.load(f)
40
+ else:
41
+ logger.debug("Backlog file exists but is empty. Starting with empty backlog.")
42
+ return []
43
+ except json.JSONDecodeError as e:
44
+ logger.error(f"Error loading backlog: {e}")
45
+ logger.info("Creating new empty backlog due to loading error")
46
+ self.backlog = []
47
+ self._save_backlog()
48
+ return []
49
+ except Exception as e:
50
+ logger.error(f"Error loading backlog: {e}")
51
+ else:
52
+ # File does not exist, create it
53
+ try:
54
+ with open(self.backlog_file, 'w', encoding='utf-8') as f:
55
+ json.dump([], f)
56
+ logger.info(f"Created new backlog file: {self.backlog_file}")
57
+ except Exception as e:
58
+ logger.error(f"Failed to create backlog file: {e}")
59
+ return []
60
+ return []
61
+
62
+ def _save_backlog(self):
63
+ """Save the backlog to file"""
64
+ try:
65
+ # Specify encoding for writing JSON
66
+ with open(self.backlog_file, 'w', encoding='utf-8') as f:
67
+ json.dump(self.backlog, f, indent=4) # Add indent for readability
68
+ except Exception as e:
69
+ logger.error(f"Error saving backlog: {e}")
70
+
71
+ def add(self, simkl_id, title):
72
+ """Add a movie to the backlog"""
73
+ entry = {
74
+ "simkl_id": simkl_id,
75
+ "title": title,
76
+ "timestamp": datetime.now().isoformat()
77
+ }
78
+
79
+ # Don't add duplicates
80
+ for item in self.backlog:
81
+ if item.get("simkl_id") == simkl_id:
82
+ return
83
+
84
+ self.backlog.append(entry)
85
+ self._save_backlog()
86
+ logger.info(f"Added '{title}' to backlog for future syncing")
87
+
88
+ def get_pending(self):
89
+ """Get all pending backlog entries"""
90
+ return self.backlog
91
+
92
+ def remove(self, simkl_id):
93
+ """Remove an entry from the backlog"""
94
+ self.backlog = [item for item in self.backlog if item.get("simkl_id") != simkl_id]
95
+ self._save_backlog()
96
+
97
+ def clear(self):
98
+ """Clear the entire backlog"""
99
+ self.backlog = []
100
+ self._save_backlog()
simkl_mps/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """
2
+ Command-Line Interface (CLI) for the Media Player Scrobbler for SIMKL application.
3
+
4
+ Provides commands for initialization, starting/stopping the service,
5
+ managing the background service, and checking status.
6
+ """
7
+ import argparse
8
+ import sys
9
+ import os
10
+ import colorama
11
+ import subprocess
12
+ import logging
13
+ import importlib.metadata
14
+ from pathlib import Path
15
+ from colorama import Fore, Style
16
+
17
+ VERSION = "1.0.0" # Default fallback version
18
+
19
+ def get_version():
20
+ """Get version information dynamically, using modern approaches."""
21
+
22
+ try:
23
+
24
+ for pkg_name in ['simkl-mps', 'simkl_mps']:
25
+ try:
26
+ return importlib.metadata.version(pkg_name)
27
+ except importlib.metadata.PackageNotFoundError:
28
+ pass
29
+ except (ImportError, AttributeError):
30
+ pass
31
+
32
+ try:
33
+ import subprocess
34
+ try:
35
+
36
+ result = subprocess.run(
37
+ ['git', 'describe', '--tags', '--always'],
38
+ stdout=subprocess.PIPE,
39
+ stderr=subprocess.PIPE,
40
+ text=True,
41
+ check=False
42
+ )
43
+ if result.returncode == 0 and result.stdout:
44
+ return result.stdout.strip()
45
+ except (subprocess.SubprocessError, FileNotFoundError):
46
+ pass
47
+ except ImportError:
48
+ pass
49
+
50
+ try:
51
+ from simkl_mps import __version__
52
+ return __version__
53
+ except (ImportError, AttributeError):
54
+ pass
55
+
56
+ try:
57
+
58
+ import simkl_mps
59
+ pkg_dir = Path(simkl_mps.__file__).parent
60
+ version_file = pkg_dir / 'VERSION'
61
+ if version_file.exists():
62
+ return version_file.read_text().strip()
63
+ except (ImportError, AttributeError, OSError):
64
+ pass
65
+
66
+ return VERSION
67
+
68
+ VERSION = get_version()
69
+
70
+
71
+ if len(sys.argv) > 1 and sys.argv[1] in ["--version", "-v", "version"]:
72
+ print(f"simkl-mps v{VERSION}")
73
+ print(f"Python: {sys.version.split()[0]}")
74
+ print(f"Platform: {sys.platform}")
75
+ sys.exit(0)
76
+
77
+ from simkl_mps.simkl_api import authenticate
78
+ from simkl_mps.credentials import get_credentials, get_env_file_path
79
+ from simkl_mps.main import SimklScrobbler, APP_DATA_DIR # Import APP_DATA_DIR for log path display
80
+ from simkl_mps.tray_app import run_tray_app
81
+
82
+ colorama.init()
83
+ logger = logging.getLogger(__name__)
84
+
85
+ def _check_prerequisites(check_token=True, check_client_id=True):
86
+ """Helper function to check if credentials exist before running a command."""
87
+ env_path = get_env_file_path()
88
+ creds = get_credentials()
89
+ error = False
90
+ if check_client_id and not creds.get("client_id"):
91
+ print(f"{Fore.RED}ERROR: Client ID is missing. Application build might be corrupted. Please reinstall.{Style.RESET_ALL}", file=sys.stderr)
92
+ error = True
93
+ if check_token and not creds.get("access_token"):
94
+ print(f"{Fore.RED}ERROR: Access Token not found in '{env_path}'. Please run 'simkl-mps init' first.{Style.RESET_ALL}", file=sys.stderr)
95
+ error = True
96
+ return not error
97
+
98
+ def init_command(args):
99
+ """
100
+ Handles the 'init' command.
101
+
102
+ Checks existing credentials, performs OAuth device flow if necessary,
103
+ and saves the access token. Verifies the final configuration.
104
+ """
105
+ print(f"{Fore.CYAN}=== Media Player Scrobbler for SIMKL Initialization ==={Style.RESET_ALL}")
106
+ env_path = get_env_file_path()
107
+ print(f"[*] Using Access Token file: {env_path}")
108
+ logger.info("Initiating initialization process.")
109
+
110
+ print("[*] Loading credentials...")
111
+ creds = get_credentials()
112
+ client_id = creds.get("client_id")
113
+ access_token = creds.get("access_token")
114
+
115
+ if not client_id or not creds.get("client_secret"):
116
+ logger.critical("Initialization failed: Client ID or Secret missing (build issue).")
117
+ print(f"{Fore.RED}CRITICAL ERROR: Client ID or Secret not found. Build may be corrupted. Please reinstall.{Style.RESET_ALL}", file=sys.stderr)
118
+ return 1
119
+ else:
120
+ logger.debug("Client ID and Secret loaded successfully (from build).")
121
+ print(f"{Fore.GREEN}[✓] Client ID/Secret loaded successfully.{Style.RESET_ALL}")
122
+
123
+ if access_token:
124
+ logger.info("Existing access token found.")
125
+ print(f"{Fore.GREEN}[✓] Access Token found.{Style.RESET_ALL}")
126
+ print(f"{Fore.YELLOW}[!] Skipping authentication process.{Style.RESET_ALL}")
127
+ else:
128
+ logger.warning("Access token not found, initiating authentication.")
129
+ print(f"{Fore.YELLOW}[!] Access Token not found. Starting authentication...{Style.RESET_ALL}")
130
+
131
+ new_access_token = authenticate(client_id)
132
+
133
+ if not new_access_token:
134
+ logger.error("Authentication process failed or was cancelled.")
135
+ print(f"{Fore.RED}ERROR: Authentication failed or was cancelled.{Style.RESET_ALL}", file=sys.stderr)
136
+ return 1
137
+
138
+ logger.info("Authentication successful, saving new access token.")
139
+ print(f"\n[*] Saving new access token to: {env_path}")
140
+ try:
141
+
142
+ env_path.parent.mkdir(parents=True, exist_ok=True)
143
+ with open(env_path, "w", encoding='utf-8') as env_file:
144
+
145
+ env_file.write("# Simkl Access Token obtained via 'simkl-mps init'\n")
146
+ env_file.write(f"SIMKL_ACCESS_TOKEN={new_access_token}\n")
147
+ logger.info(f"Access token successfully saved to {env_path}.")
148
+ print(f"{Fore.GREEN}[✓] Access token saved successfully.{Style.RESET_ALL}")
149
+
150
+ access_token = new_access_token
151
+ except IOError as e:
152
+ logger.exception(f"Failed to save access token to {env_path}: {e}")
153
+ print(f"{Fore.RED}ERROR: Failed to save access token: {e}{Style.RESET_ALL}", file=sys.stderr)
154
+ return 1
155
+
156
+ print(f"\n[*] Verifying application configuration...")
157
+ logger.info("Verifying configuration by initializing SimklScrobbler instance.")
158
+ verifier_scrobbler = SimklScrobbler()
159
+ if not verifier_scrobbler.initialize():
160
+ logger.error("Configuration verification failed after initialization attempt.")
161
+ print(f"{Fore.RED}ERROR: Configuration verification failed. Check logs for details: {APP_DATA_DIR / 'simkl_mps.log'}{Style.RESET_ALL}", file=sys.stderr)
162
+ print(f"{Fore.YELLOW}Hint: If the token seems valid but verification fails, check Simkl API status or report a bug.{Style.RESET_ALL}")
163
+ return 1
164
+
165
+ logger.info("Initialization and verification successful.")
166
+ print(f"\n{Fore.GREEN}========================================={Style.RESET_ALL}")
167
+ print(f"{Fore.GREEN}✓ Initialization Complete!{Style.RESET_ALL}")
168
+ print(f"{Fore.GREEN}========================================={Style.RESET_ALL}")
169
+ print(f"\n[*] To start monitoring and scrobbling, run:")
170
+ print(f" {Fore.WHITE}simkl-mps start{Style.RESET_ALL}")
171
+ return 0
172
+
173
+ def start_command(args):
174
+ """
175
+ Handles the 'start' command.
176
+
177
+ Installs the application as a startup service, launches the service,
178
+ and launches the tray application in a detached background process.
179
+ All components run in background - closing terminal won't affect function.
180
+ """
181
+ print(f"{Fore.CYAN}=== Starting Media Player Scrobbler for SIMKL ==={Style.RESET_ALL}")
182
+ logger.info("Executing start command.")
183
+
184
+ if not _check_prerequisites():
185
+ print(f"{Fore.YELLOW}[!] No access token found. Running initialization...{Style.RESET_ALL}")
186
+ init_result = init_command(args)
187
+ if init_result != 0:
188
+ print(f"{Fore.RED}ERROR: Initialization failed. Cannot start application.{Style.RESET_ALL}", file=sys.stderr)
189
+ return 1
190
+
191
+ if not _check_prerequisites():
192
+ print(f"{Fore.RED}ERROR: Still missing credentials after initialization. Aborting start.{Style.RESET_ALL}", file=sys.stderr)
193
+ return 1
194
+
195
+ if os.environ.get("SIMKL_TRAY_SUBPROCESS") == "1":
196
+ logger.info("Detected we're in the tray subprocess - running tray app directly")
197
+ print("Running tray application directly...")
198
+ from simkl_mps.tray_app import run_tray_app
199
+ sys.exit(run_tray_app())
200
+
201
+ print("[*] Launching application with tray icon in background...")
202
+ logger.info("Launching tray application in detached process.")
203
+
204
+ try:
205
+ # Determine the command to launch the tray application
206
+ if getattr(sys, 'frozen', False):
207
+ # We're running in a PyInstaller bundle
208
+ exe_dir = Path(sys.executable).parent
209
+
210
+ # Look for the dedicated tray executable - now named "MPS for Simkl.exe"
211
+ tray_exe_paths = [
212
+ exe_dir / "MPS for Simkl.exe", # Windows - new name
213
+ exe_dir / "MPS for Simkl", # Linux/macOS - new name
214
+ ]
215
+
216
+ # Use the first tray executable that exists
217
+ for tray_path in tray_exe_paths:
218
+ if tray_path.exists():
219
+ cmd = [str(tray_path)]
220
+ logger.info(f"Using dedicated tray executable: {tray_path}")
221
+ break
222
+ else:
223
+ # No dedicated tray executable found - use the main executable with the tray parameter
224
+ cmd = [sys.executable, "tray"]
225
+ logger.info("Using main executable with 'tray' parameter as fallback")
226
+ else:
227
+ # Not frozen - launch as a Python module
228
+ cmd = [sys.executable, "-m", "simkl_mps.tray_app"]
229
+ logger.info("Launching tray via Python module (development mode)")
230
+
231
+ # Set up environment for subprocess
232
+ env = os.environ.copy()
233
+ env["SIMKL_TRAY_SUBPROCESS"] = "1" # Mark as subprocess
234
+
235
+ if sys.platform == "win32":
236
+ # Windows-specific process creation
237
+ CREATE_NO_WINDOW = 0x08000000
238
+ DETACHED_PROCESS = 0x00000008
239
+
240
+ startupinfo = subprocess.STARTUPINFO()
241
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
242
+
243
+ subprocess.Popen(
244
+ cmd,
245
+ creationflags=CREATE_NO_WINDOW | DETACHED_PROCESS,
246
+ close_fds=True,
247
+ shell=False,
248
+ startupinfo=startupinfo,
249
+ env=env
250
+ )
251
+ logger.info("Launched detached process on Windows")
252
+ else:
253
+ # Unix-like systems (Linux, macOS)
254
+ subprocess.Popen(
255
+ cmd,
256
+ start_new_session=True,
257
+ stdout=subprocess.DEVNULL,
258
+ stderr=subprocess.DEVNULL,
259
+ close_fds=True,
260
+ shell=False,
261
+ env=env
262
+ )
263
+ logger.info("Launched detached process on Unix-like system")
264
+
265
+ print(f"{Fore.GREEN}[✓] Scrobbler launched successfully in background.{Style.RESET_ALL}")
266
+ print(f"[*] Look for the SIMKL-MPS icon in your system tray.")
267
+ print(f"{Fore.GREEN}[✓] You can safely close this terminal window. All processes will continue running.{Style.RESET_ALL}")
268
+ return 0
269
+ except Exception as e:
270
+ logger.exception(f"Failed to launch detached tray process: {e}")
271
+ print(f"{Fore.RED}ERROR: Failed to launch application in background: {e}{Style.RESET_ALL}", file=sys.stderr)
272
+ return 1
273
+
274
+ def tray_command(args):
275
+ """
276
+ Handles the 'tray' command.
277
+
278
+ Runs ONLY the tray application attached to the current terminal.
279
+ Logs will be printed to the terminal.
280
+ Closing the terminal will stop the application.
281
+ """
282
+ print(f"{Fore.CYAN}=== Starting Media Player Scrobbler for SIMKL (Tray Foreground Mode) ==={Style.RESET_ALL}")
283
+ logger.info("Executing tray command (foreground).")
284
+ if not _check_prerequisites(): return 1
285
+
286
+ print("[*] Launching tray application in foreground...")
287
+ print("[*] Logs will be printed below. Press Ctrl+C to exit.")
288
+ try:
289
+
290
+
291
+ from simkl_mps.tray_app import run_tray_app
292
+ return run_tray_app() # Run directly and return its exit code
293
+ except KeyboardInterrupt:
294
+ logger.info("Tray application stopped by user (Ctrl+C).")
295
+ print("\n[*] Tray application stopped.")
296
+ return 0
297
+ except Exception as e:
298
+ logger.exception(f"Failed to run tray application in foreground: {e}")
299
+ print(f"{Fore.RED}ERROR: Failed to run tray application: {e}{Style.RESET_ALL}", file=sys.stderr)
300
+ return 1
301
+
302
+
303
+ def version_command(args):
304
+ """
305
+ Displays version information about the application.
306
+
307
+ Shows the current installed version of simkl-mps.
308
+ """
309
+ print(f"{Fore.CYAN}=== simkl-mps Version Information ==={Style.RESET_ALL}")
310
+ logger.info(f"Displaying version information: {VERSION}")
311
+
312
+ print(f"simkl-mps v{VERSION}")
313
+ print(f"Python: {sys.version.split()[0]}")
314
+ print(f"Platform: {sys.platform}")
315
+
316
+ if getattr(sys, 'frozen', False):
317
+ print(f"Installation: Packaged executable")
318
+ print(f"Executable: {sys.executable}")
319
+ else:
320
+ print(f"Installation: Running from source")
321
+
322
+ print(f"\nData directory: {APP_DATA_DIR}")
323
+ return 0
324
+
325
+ def check_for_updates(silent=False):
326
+ """
327
+ Check for updates to the application.
328
+
329
+ Args:
330
+ silent (bool): If True, run silently with no user interaction
331
+
332
+ Returns:
333
+ bool: True if update check was successful, False otherwise
334
+ """
335
+ logger.info("Checking for updates...")
336
+
337
+ try:
338
+ import subprocess
339
+ import os
340
+ from pathlib import Path
341
+
342
+ # Get the path to the updater script
343
+ if getattr(sys, 'frozen', False):
344
+ # Running as frozen executable
345
+ updater_path = Path(sys.executable).parent / "updater.ps1"
346
+ else:
347
+ # Running in development mode
348
+ updater_path = Path(__file__).parent / "utils" / "updater.ps1"
349
+
350
+ if not updater_path.exists():
351
+ logger.error(f"Updater script not found at {updater_path}")
352
+ return False
353
+
354
+ # Build the PowerShell command
355
+ args = [
356
+ "powershell.exe",
357
+ "-ExecutionPolicy", "Bypass",
358
+ "-File", str(updater_path)
359
+ ]
360
+
361
+ if silent:
362
+ args.append("-Silent")
363
+
364
+ args.append("-CheckOnly") # Just check, don't install automatically
365
+
366
+ # Run the updater
367
+ logger.debug(f"Running updater: {' '.join(args)}")
368
+ subprocess.Popen(args)
369
+ return True
370
+
371
+ except Exception as e:
372
+ logger.error(f"Error checking for updates: {e}")
373
+ return False
374
+
375
+ def create_parser():
376
+ """
377
+ Creates and configures the argument parser for the CLI.
378
+
379
+ Returns:
380
+ argparse.ArgumentParser: The configured argument parser.
381
+ """
382
+ parser = argparse.ArgumentParser(
383
+ description="simkl-mps: Automatically scrobble movie watch history to Simkl.",
384
+ formatter_class=argparse.RawTextHelpFormatter # Preserve help text formatting
385
+ )
386
+
387
+ parser.add_argument("--version", "-v", action="store_true",
388
+ help="Display version information and exit")
389
+
390
+ subparsers = parser.add_subparsers(dest="command", help="Available commands", required=True) # Make command required
391
+
392
+ init_parser = subparsers.add_parser(
393
+ "init",
394
+ aliases=['i'],
395
+ help="Initialize or re-authenticate the scrobbler with your Simkl account."
396
+ )
397
+
398
+ start_parser = subparsers.add_parser(
399
+ "start",
400
+ aliases=['s'],
401
+ help="Run ALL components (background service + tray icon). Terminal can be closed."
402
+ )
403
+
404
+ tray_parser = subparsers.add_parser(
405
+ "tray",
406
+ aliases=['t'],
407
+ help="Run ONLY tray icon attached to the terminal (shows logs)."
408
+ )
409
+
410
+
411
+ version_parser = subparsers.add_parser(
412
+ "version",
413
+ aliases=['V'],
414
+ help="Display the current installed version of simkl-mps."
415
+ )
416
+
417
+ return parser
418
+
419
+ def main():
420
+ """
421
+ Main entry point for the CLI application.
422
+
423
+ Parses arguments and dispatches to the appropriate command function.
424
+
425
+ Returns:
426
+ int: Exit code (0 for success, 1 for errors).
427
+ """
428
+ parser = create_parser()
429
+ args = parser.parse_args()
430
+
431
+ if getattr(args, 'version', False):
432
+ return version_command(args)
433
+
434
+ if not hasattr(args, 'command') or not args.command:
435
+ parser.print_help()
436
+ return 0
437
+
438
+ # Check for updates when starting the app (except for the tray subprocess)
439
+ if os.environ.get("SIMKL_TRAY_SUBPROCESS") != "1" and args.command in ["start", "tray"]:
440
+ # Check if user has enabled update checks
441
+ import winreg
442
+ try:
443
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\kavinthangavel\Media Player Scrobbler for SIMKL") as key:
444
+ check_updates = winreg.QueryValueEx(key, "CheckUpdates")[0]
445
+ if check_updates == 1:
446
+ logger.info("Auto-update check enabled, checking for updates...")
447
+ check_for_updates(silent=True)
448
+ except (OSError, ImportError, Exception) as e:
449
+ # If registry key doesn't exist or other error, default to checking for updates
450
+ logger.debug(f"Error checking update preferences, defaulting to check: {e}")
451
+ check_for_updates(silent=True)
452
+
453
+ command_map = {
454
+ "init": init_command,
455
+ "start": start_command,
456
+ "tray": tray_command,
457
+ "version": version_command,
458
+ "help": lambda _: parser.print_help()
459
+ }
460
+
461
+ if args.command in command_map:
462
+ try:
463
+ logger.info(f"Executing command: {args.command}")
464
+ exit_code = command_map[args.command](args)
465
+ logger.info(f"Command '{args.command}' finished with exit code {exit_code}.")
466
+ return exit_code
467
+ except Exception as e:
468
+
469
+ logger.exception(f"Unhandled exception during command '{args.command}': {e}")
470
+ print(f"\n{Fore.RED}UNEXPECTED ERROR: An error occurred during the '{args.command}' command.{Style.RESET_ALL}", file=sys.stderr)
471
+ print(f"{Fore.RED}Details: {e}{Style.RESET_ALL}", file=sys.stderr)
472
+ print(f"{Fore.YELLOW}Please check the log file for more information: {APP_DATA_DIR / 'simkl_mps.log'}{Style.RESET_ALL}", file=sys.stderr)
473
+ return 1
474
+ else:
475
+
476
+ logger.error(f"Unknown command received: {args.command}")
477
+ parser.print_help()
478
+ return 1
479
+
480
+ if __name__ == "__main__":
481
+
482
+ sys.exit(main())
@@ -0,0 +1,16 @@
1
+ """
2
+ Compatibility patches for libraries that haven't been updated for Python 3.10+
3
+ """
4
+ import collections
5
+ import collections.abc
6
+
7
+
8
+ for abc_class in ['MutableMapping', 'Mapping', 'Sequence', 'MutableSequence', 'Set', 'MutableSet']:
9
+ if not hasattr(collections, abc_class):
10
+ setattr(collections, abc_class, getattr(collections.abc, abc_class))
11
+
12
+ def apply_patches():
13
+ """Apply all compatibility patches"""
14
+ # Currently all patches are applied at import time, but more complex
15
+ # patching logic can be added here if needed later
16
+ pass