simkl-mps 2.2.2__tar.gz → 2.3.1__tar.gz

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 (63) hide show
  1. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/PKG-INFO +1 -12
  2. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/README.md +0 -11
  3. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/pyproject.toml +1 -1
  4. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/config_manager.py +24 -1
  5. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_scrobbler.py +53 -9
  6. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/monitor.py +1 -1
  7. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_base.py +169 -3
  8. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_linux.py +62 -18
  9. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_mac.py +25 -17
  10. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_win.py +170 -119
  11. simkl_mps-2.3.1/simkl_mps/utils/path_filter.py +129 -0
  12. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/updater.ps1 +174 -23
  13. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/LICENSE +0 -0
  14. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/__init__.py +0 -0
  15. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps (Custom).png +0 -0
  16. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-128.png +0 -0
  17. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-16.png +0 -0
  18. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-24.png +0 -0
  19. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-256.png +0 -0
  20. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-32.png +0 -0
  21. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-48.png +0 -0
  22. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-64.png +0 -0
  23. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-error.ico +0 -0
  24. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-error.png +0 -0
  25. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-paused.ico +0 -0
  26. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-paused.png +0 -0
  27. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-running.ico +0 -0
  28. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-running.png +0 -0
  29. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-stopped.ico +0 -0
  30. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-stopped.png +0 -0
  31. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps.ico +0 -0
  32. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps.png +0 -0
  33. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/backlog_cleaner.py +0 -0
  34. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/cli.py +0 -0
  35. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/compatibility_patches.py +0 -0
  36. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/credentials.py +0 -0
  37. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/main.py +0 -0
  38. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_cache.py +0 -0
  39. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_tracker.py +0 -0
  40. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/migration.py +0 -0
  41. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/__init__.py +0 -0
  42. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpc.py +0 -0
  43. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpcqt.py +0 -0
  44. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpv.py +0 -0
  45. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpv_wrappers.py +0 -0
  46. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/potplayer.py +0 -0
  47. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/vlc.py +0 -0
  48. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/simkl_api.py +0 -0
  49. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_app.py +0 -0
  50. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/__init__.py +0 -0
  51. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/constants.py +0 -0
  52. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/linux_tray_diagnostics.py +0 -0
  53. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/updater.sh +0 -0
  54. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Bold.woff2 +0 -0
  55. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Medium.woff2 +0 -0
  56. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Regular.woff2 +0 -0
  57. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-SemiBold.woff2 +0 -0
  58. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts.css +0 -0
  59. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/index.html +0 -0
  60. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/script.js +0 -0
  61. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/style.css +0 -0
  62. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch_history_manager.py +0 -0
  63. {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/window_detection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simkl-mps
3
- Version: 2.2.2
3
+ Version: 2.3.1
4
4
  Summary: Automatic Media Scrobbler for Simkl
5
5
  License: GPL-3.0
6
6
  License-File: LICENSE
@@ -70,17 +70,6 @@ Description-Content-Type: text/markdown
70
70
 
71
71
  After installation, authenticate with SIMKL and **configure your media players** using the [Media Players Guide](docs/media-players.md) (this step is critical for accurate tracking).
72
72
 
73
- ## 📚 Documentation
74
-
75
- - [Windows Guide](docs/windows-guide.md)
76
- - [Linux Guide](docs/linux-guide.md)
77
- - [Mac Guide](docs/mac-guide.md)
78
- - [Supported Media Players](docs/media-players.md)
79
- - [Usage Guide](docs/usage.md)
80
- - [Local Watch History](docs/watch-history.md)
81
- - [Advanced & Developer Guide](docs/configuration.md)
82
- - [Troubleshooting Guide](docs/troubleshooting.md)
83
- - [Todo List](docs/todo.md)
84
73
 
85
74
  ## 🔍 How It Works
86
75
 
@@ -26,17 +26,6 @@
26
26
 
27
27
  After installation, authenticate with SIMKL and **configure your media players** using the [Media Players Guide](docs/media-players.md) (this step is critical for accurate tracking).
28
28
 
29
- ## 📚 Documentation
30
-
31
- - [Windows Guide](docs/windows-guide.md)
32
- - [Linux Guide](docs/linux-guide.md)
33
- - [Mac Guide](docs/mac-guide.md)
34
- - [Supported Media Players](docs/media-players.md)
35
- - [Usage Guide](docs/usage.md)
36
- - [Local Watch History](docs/watch-history.md)
37
- - [Advanced & Developer Guide](docs/configuration.md)
38
- - [Troubleshooting Guide](docs/troubleshooting.md)
39
- - [Todo List](docs/todo.md)
40
29
 
41
30
  ## 🔍 How It Works
42
31
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "simkl-mps"
3
- version = "2.2.2"
3
+ version = "2.3.1"
4
4
  description = "Automatic Media Scrobbler for Simkl"
5
5
  authors = [
6
6
  "kavin <kavinthangavel.dev@gmail.com>",
@@ -36,9 +36,22 @@ DEFAULT_THRESHOLD = 80
36
36
  DEFAULT_SETTINGS = {
37
37
  "watch_completion_threshold": DEFAULT_THRESHOLD,
38
38
  "user_subdir": DEFAULT_USER_SUBDIR,
39
- "auto_sync_interval": 120 # Auto sync backlog every 2 minutes by default
39
+ "auto_sync_interval": 120, # Auto sync backlog every 2 minutes by default
40
+ "disable_notifications": False, # Show all notifications by default
41
+ "allow_dirs": [],
42
+ "deny_dirs": []
40
43
  }
41
44
 
45
+ def _sanitize_dir_list(value):
46
+ """Ensure allow/deny dir settings are stored as a list of strings."""
47
+ if value is None:
48
+ return []
49
+ if isinstance(value, str):
50
+ return [value]
51
+ if isinstance(value, (list, tuple)):
52
+ return [str(item) for item in value if isinstance(item, str) and item.strip()]
53
+ return []
54
+
42
55
  def load_settings():
43
56
  """Loads settings from the JSON file in the user config directory."""
44
57
  if not SETTINGS_FILE.exists():
@@ -76,6 +89,13 @@ def load_settings():
76
89
  settings['watch_completion_threshold'] = DEFAULT_THRESHOLD
77
90
  settings_updated = True
78
91
 
92
+ # Sanitize allow/deny directory lists
93
+ for key in ("allow_dirs", "deny_dirs"):
94
+ sanitized = _sanitize_dir_list(settings.get(key))
95
+ if settings.get(key) != sanitized:
96
+ settings[key] = sanitized
97
+ settings_updated = True
98
+
79
99
  # Save the file only if defaults were added or invalid values corrected
80
100
  if settings_updated:
81
101
  save_settings(settings)
@@ -125,6 +145,9 @@ def set_setting(key, value):
125
145
  except (ValueError, TypeError):
126
146
  log.error(f"Attempted to set non-integer watch_completion_threshold: {value}.")
127
147
  return # Do not save invalid value
148
+
149
+ if key in ('allow_dirs', 'deny_dirs'):
150
+ value = _sanitize_dir_list(value)
128
151
 
129
152
  log.debug(f"ConfigManager: set_setting proceeding for key='{key}' before user_subdir check.")
130
153
  if key == 'user_subdir' and value != get_setting('user_subdir'):
@@ -40,6 +40,7 @@ except ImportError:
40
40
  from simkl_mps.utils.constants import PLAYING, PAUSED, STOPPED, DEFAULT_POLL_INTERVAL
41
41
  from simkl_mps.config_manager import get_setting, DEFAULT_THRESHOLD
42
42
  from simkl_mps.watch_history_manager import WatchHistoryManager
43
+ from simkl_mps.utils.path_filter import is_path_allowed
43
44
 
44
45
  class MediaScrobbler:
45
46
  """
@@ -127,6 +128,23 @@ class MediaScrobbler:
127
128
  self._mpv_wrapper_integration = None
128
129
  self._potplayer_integration = None
129
130
  self.watch_history = WatchHistoryManager(self.app_data_dir) # Initialize watch history manager
131
+ self._allow_dirs = get_setting('allow_dirs', [])
132
+ self._deny_dirs = get_setting('deny_dirs', [])
133
+ self._dir_filter_last_refresh = 0
134
+
135
+ def _refresh_dir_filters(self, min_interval_seconds=60):
136
+ """Refresh directory allow/deny lists periodically to avoid frequent disk I/O."""
137
+ current_time = time.time()
138
+ if current_time - self._dir_filter_last_refresh < min_interval_seconds:
139
+ return
140
+ self._allow_dirs = get_setting('allow_dirs', [])
141
+ self._deny_dirs = get_setting('deny_dirs', [])
142
+ self._dir_filter_last_refresh = current_time
143
+
144
+ def signal_dir_filters_update(self):
145
+ """Signal that directory filters have been updated and should be refreshed on the next check."""
146
+ self._dir_filter_last_refresh = 0
147
+ logger.info("Received signal to refresh directory filters.")
130
148
 
131
149
  def set_notification_callback(self, callback):
132
150
  """Set a callback function for notifications"""
@@ -149,11 +167,23 @@ class MediaScrobbler:
149
167
  else:
150
168
  logger.debug("Backlog processing state and notification throttles were already empty")
151
169
 
152
- def _send_notification(self, title, message, online_only=False, offline_only=False):
170
+ def _send_notification(self, title, message, online_only=False, offline_only=False, critical=False):
153
171
  """
154
172
  Safely sends a notification if the callback is set, respecting online/offline constraints.
173
+
174
+ Args:
175
+ title: Notification title
176
+ message: Notification message
177
+ online_only: Only send when internet is connected
178
+ offline_only: Only send when offline
179
+ critical: If True, always send regardless of user's disable_notifications setting
155
180
  """
156
181
  if self.notification_callback:
182
+ # Check if notifications are disabled for non-critical notifications
183
+ if not critical and get_setting('disable_notifications', False):
184
+ logger.debug(f"Notification '{title}' suppressed (disable_notifications setting is True).")
185
+ return
186
+
157
187
  connected = is_internet_connected()
158
188
  if (online_only and not connected) or \
159
189
  (offline_only and connected):
@@ -314,21 +344,24 @@ class MediaScrobbler:
314
344
  else:
315
345
  logger.debug(f"{player_name} integration couldn't get position/duration.")
316
346
  except requests.RequestException as e:
347
+ # Connection errors during player shutdown are expected (debug level)
348
+ logger.debug(f"Could not connect to {process_name} web interface. Error: {e}")
349
+ # Only log warnings and notify for persistent connection issues (throttled to once per 5 minutes)
317
350
  now = time.time()
318
351
  last_log_time = self._last_connection_error_log.get(process_name, 0)
319
- if now - last_log_time > 60: # Log connection errors at most once per minute per player
320
- logger.warning(f"Could not connect to {process_name} web interface. Error: {e}")
352
+ if now - last_log_time > 300: # Notify at most once per 5 minutes per player
353
+ logger.warning(f"Persistent connection issue with {process_name} web interface. {str(e)[:100]}")
321
354
  self._last_connection_error_log[process_name] = now
322
355
  # Only notify if currently tracking a file
323
356
  if self.currently_tracking:
324
357
  player_type = self._get_player_type(process_name_lower)
325
358
  if player_type:
326
359
  config_instructions = self._get_player_config_instructions(player_type)
327
- logger.info(f"[DEBUG] Sending notification for {player_type} connection error: {config_instructions}")
328
360
  self._send_notification(
329
361
  f"{player_type} Connection Error",
330
362
  f"Could not connect to {player_type}. {config_instructions}",
331
- online_only=False
363
+ online_only=False,
364
+ critical=True
332
365
  )
333
366
  except Exception as e:
334
367
  logger.error(f"Error getting pos/dur from {process_name} ({getattr(integration, '__class__', type(integration)).__name__}): {e}", exc_info=True)
@@ -353,10 +386,13 @@ class MediaScrobbler:
353
386
  logger.debug(f"Retrieved filepath from {player_name}: {filepath}")
354
387
  return filepath
355
388
  except requests.RequestException as e:
389
+ # Connection errors during player shutdown are expected (debug level)
390
+ logger.debug(f"Could not connect to {process_name} web interface for filepath. Error: {e}")
391
+ # Only log warnings and notify for persistent connection issues (throttled to once per 5 minutes)
356
392
  now = time.time()
357
393
  last_log_time = self._last_connection_error_log.get(process_name, 0)
358
- if now - last_log_time > 60: # Log and notify at most once per minute per player
359
- logger.warning(f"Could not connect to {process_name} web interface for filepath. Error: {e}")
394
+ if now - last_log_time > 300: # Notify at most once per 5 minutes per player
395
+ logger.warning(f"Persistent connection issue with {process_name} web interface. {str(e)[:100]}")
360
396
  self._last_connection_error_log[process_name] = now
361
397
  # Send notification about web interface connection error
362
398
  player_type = self._get_player_type(process_name_lower)
@@ -365,7 +401,8 @@ class MediaScrobbler:
365
401
  self._send_notification(
366
402
  f"{player_type} Connection Error",
367
403
  f"Could not connect to {player_type} web interface. {config_instructions}",
368
- online_only=False
404
+ online_only=False,
405
+ critical=True
369
406
  )
370
407
  except Exception as e:
371
408
  logger.error(f"Error getting filepath from {process_name} ({integration.__class__.__name__}): {e}", exc_info=True)
@@ -403,6 +440,13 @@ class MediaScrobbler:
403
440
  self.stop_tracking()
404
441
  return None
405
442
 
443
+ self._refresh_dir_filters()
444
+ if not is_path_allowed(filepath, self._allow_dirs, self._deny_dirs):
445
+ logger.info("Filepath excluded by directory filters: '%s'", filepath)
446
+ if self.currently_tracking:
447
+ self.stop_tracking()
448
+ return None
449
+
406
450
  # Detect media switches even when guessit returns identical titles (e.g., sequential episodes)
407
451
  if self.currently_tracking and self.current_filepath and filepath:
408
452
  if self._has_media_file_changed(self.current_filepath, filepath):
@@ -1367,7 +1411,7 @@ class MediaScrobbler:
1367
1411
  self.simkl_id, display_title, "missing_credentials",
1368
1412
  {"simkl_id": self.simkl_id, "type": self.media_type, "season": self.season, "episode": self.episode}
1369
1413
  )
1370
- self._send_notification("Auth Error", f"'{display_title}' needs sync (missing creds). Added to backlog.")
1414
+ self._send_notification("Auth Error", f"'{display_title}' needs sync (missing creds). Added to backlog.", critical=True)
1371
1415
  return False
1372
1416
 
1373
1417
  # --- Identification Check ---
@@ -153,7 +153,7 @@ class Monitor:
153
153
  # else:
154
154
  # logger.debug(f"Skipping repeated search for '{title}' (cooldown: {int(self.offline_search_cooldown - time_since_last_attempt)}s remaining)")
155
155
  else:
156
- logger.warning(f"No scrobble info returned from process_window for {process_name}")
156
+ logger.debug(f"No scrobble info returned from process_window for {process_name}")
157
157
  # Update the persistent state *after* processing and logging checks
158
158
  self.last_known_player_process = process_name
159
159
  self.last_known_filepath = filepath
@@ -11,6 +11,8 @@ import queue # Added for thread-safe communication for custom threshold dialog
11
11
  import logging
12
12
  import webbrowser
13
13
  import subprocess
14
+ import re
15
+ from importlib import metadata as importlib_metadata
14
16
  from pathlib import Path
15
17
  from typing import Any, Optional, TYPE_CHECKING
16
18
  from PIL import Image, ImageDraw, ImageFont
@@ -86,6 +88,16 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
86
88
  The new threshold value (int) entered by the user, or None if cancelled.
87
89
  """
88
90
  pass
91
+
92
+ @abc.abstractmethod
93
+ def _ask_directory_filter_dialog(self, title: str, current_value: str, help_text: str) -> str | None:
94
+ """
95
+ Platform-specific dialog for editing directory filters.
96
+
97
+ Returns:
98
+ Updated string (comma/newline separated) or None if cancelled.
99
+ """
100
+ pass
89
101
 
90
102
  def _show_confirmation_dialog(self, title, message):
91
103
  """
@@ -248,7 +260,116 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
248
260
  except (TypeError, ValueError):
249
261
  return DEFAULT_THRESHOLD
250
262
 
251
- def check_updates_thread(self):
263
+ def _format_dir_list_for_dialog(self, values: Any) -> str:
264
+ """Format directory lists for dialog input."""
265
+ if not values:
266
+ return ""
267
+ if isinstance(values, str):
268
+ return values
269
+ try:
270
+ return "\n".join([str(value) for value in values if value])
271
+ except Exception:
272
+ return ""
273
+
274
+ def _parse_dir_list_input(self, input_text: str | None) -> list[str]:
275
+ """Parse dialog input into a normalized list of paths or patterns."""
276
+ if not input_text:
277
+ return []
278
+ parts: list[str] = []
279
+ for line in input_text.splitlines():
280
+ for token in re.split(r"[;,]", line):
281
+ cleaned = token.strip()
282
+ if cleaned:
283
+ parts.append(cleaned)
284
+ return parts
285
+
286
+ def _apply_dir_filter_change(self, key: str, entries: list[str]) -> None:
287
+ """
288
+ Persist filter settings and signal the running scrobbler to refresh its configuration.
289
+
290
+ This respects encapsulation by using the scrobbler's public signal_dir_filters_update()
291
+ method rather than directly modifying its private attributes.
292
+ """
293
+ try:
294
+ set_setting(key, entries)
295
+ media_scrobbler = self._get_media_scrobbler()
296
+ if media_scrobbler is not None:
297
+ # Signal the scrobbler to refresh configuration via public method
298
+ if hasattr(media_scrobbler, "signal_dir_filters_update"):
299
+ media_scrobbler.signal_dir_filters_update()
300
+ label = "Allow" if key == "allow_dirs" else "Deny"
301
+ self.show_notification("Settings Updated", f"{label} directories updated.")
302
+ except Exception as exc:
303
+ logger.error(f"Failed to update {key}: {exc}", exc_info=True)
304
+ self.show_notification("Error", f"Failed to update directory filters: {exc}")
305
+
306
+ def set_allow_dirs(self, _=None):
307
+ current_value = self._format_dir_list_for_dialog(get_setting("allow_dirs", []))
308
+ help_text = "Enter paths or glob patterns, separated by commas or semicolons."
309
+ updated_text = self._ask_directory_filter_dialog("Set Allow Directories", current_value, help_text)
310
+ if updated_text is not None:
311
+ entries = self._parse_dir_list_input(updated_text)
312
+ self._apply_dir_filter_change("allow_dirs", entries)
313
+ self.update_icon()
314
+ return 0
315
+
316
+ def set_deny_dirs(self, _=None):
317
+ current_value = self._format_dir_list_for_dialog(get_setting("deny_dirs", []))
318
+ help_text = "Enter paths or glob patterns, separated by commas or semicolons."
319
+ updated_text = self._ask_directory_filter_dialog("Set Deny Directories", current_value, help_text)
320
+ if updated_text is not None:
321
+ entries = self._parse_dir_list_input(updated_text)
322
+ self._apply_dir_filter_change("deny_dirs", entries)
323
+ self.update_icon()
324
+ return 0
325
+
326
+ def clear_allow_dirs(self, _=None):
327
+ self._apply_dir_filter_change("allow_dirs", [])
328
+ self.update_icon()
329
+ return 0
330
+
331
+ def clear_deny_dirs(self, _=None):
332
+ self._apply_dir_filter_change("deny_dirs", [])
333
+ self.update_icon()
334
+ return 0
335
+
336
+ def _get_app_version(self) -> str:
337
+ """Resolve application version from installed metadata or local package source."""
338
+ # 1) Prefer installed package metadata
339
+ for dist_name in ("simkl-mps", "simkl_mps"):
340
+ try:
341
+ version = importlib_metadata.version(dist_name)
342
+ if version:
343
+ return version
344
+ except importlib_metadata.PackageNotFoundError:
345
+ continue
346
+ except Exception:
347
+ logger.debug("Failed to read version from importlib.metadata for %s", dist_name, exc_info=True)
348
+
349
+ # 2) Fallback: parse local package __init__.py version constant
350
+ try:
351
+ init_file = Path(__file__).parent / "__init__.py"
352
+ if init_file.exists():
353
+ content = init_file.read_text(encoding="utf-8", errors="ignore")
354
+ match = re.search(r"__version__\s*=\s*[\"']([^\"']+)[\"']", content)
355
+ if match:
356
+ return match.group(1).strip()
357
+ except Exception:
358
+ logger.debug("Failed to parse local __version__ from __init__.py", exc_info=True)
359
+
360
+ return "0.0.0"
361
+
362
+ def _build_about_text(self) -> str:
363
+ """Build standard About dialog text."""
364
+ return (
365
+ "Media Player Scrobbler for SIMKL\n"
366
+ f"Version: {self._get_app_version()}\n"
367
+ "Author: kavin\n"
368
+ "License: GNU GPL v3\n\n"
369
+ "Automatically track and scrobble your media to SIMKL."
370
+ )
371
+
372
+ def check_updates_thread(self, _=None):
252
373
  """Optional hook for subclasses that implement update checks."""
253
374
  logger.debug("Update check not implemented for this platform.")
254
375
 
@@ -646,7 +767,8 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
646
767
  # Only show notification if:
647
768
  # 1. This is the first run of the app after installation
648
769
  # 2. User manually started the app from the menu
649
- if self.is_first_run or is_manual_start:
770
+ # 3. Notifications are not disabled
771
+ if (self.is_first_run or is_manual_start) and not get_setting('disable_notifications', False):
650
772
  self.show_notification(
651
773
  "simkl-mps",
652
774
  "Media monitoring started"
@@ -1032,6 +1154,37 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
1032
1154
 
1033
1155
  # --- End Watch Threshold Logic ---
1034
1156
 
1157
+ def toggle_notifications_disabled(self, _=None):
1158
+ """Toggle the disable_notifications setting and show confirmation."""
1159
+ try:
1160
+ current_value = get_setting('disable_notifications', False)
1161
+ new_value = not current_value
1162
+ set_setting('disable_notifications', new_value)
1163
+
1164
+ status = "disabled" if new_value else "enabled"
1165
+ logger.info(f"Notifications {status} via tray menu")
1166
+
1167
+ # Show confirmation (always show this one, it's critical)
1168
+ # Access the scrobbler to send critical notification
1169
+ media_scrobbler = self._get_media_scrobbler()
1170
+ if media_scrobbler:
1171
+ media_scrobbler._send_notification(
1172
+ "Settings Updated",
1173
+ f"Notifications {status}. Critical alerts will still appear.",
1174
+ critical=True
1175
+ )
1176
+ else:
1177
+ # Fallback if scrobbler not available
1178
+ self.show_notification("Settings Updated", f"Notifications {status}. Critical alerts will still appear.")
1179
+
1180
+ self.update_icon() # Refresh menu to show new checkmark state
1181
+
1182
+ except Exception as e:
1183
+ logger.error(f"Error toggling notifications: {e}", exc_info=True)
1184
+ self.show_notification("Error", f"Failed to toggle notifications: {e}")
1185
+
1186
+ return 0
1187
+
1035
1188
  def check_first_run(self):
1036
1189
  """Check if this is the first time the app is being run"""
1037
1190
  # Platform-specific implementation required
@@ -1073,6 +1226,12 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
1073
1226
  pystray.MenuItem("Sync Backlog Now", self.process_backlog),
1074
1227
  pystray.MenuItem("Completion Threshold", threshold_submenu),
1075
1228
  pystray.Menu.SEPARATOR,
1229
+ pystray.MenuItem(
1230
+ "Disable Notifications",
1231
+ self.toggle_notifications_disabled,
1232
+ checked=lambda item: get_setting('disable_notifications', False)
1233
+ ),
1234
+ pystray.Menu.SEPARATOR,
1076
1235
  pystray.MenuItem("Open Local Watch History", self.open_watch_history),
1077
1236
  )))
1078
1237
  menu_items.append(pystray.Menu.SEPARATOR)
@@ -1093,6 +1252,13 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
1093
1252
  menu_items.append(pystray.MenuItem("Maintenance", pystray.Menu(
1094
1253
  pystray.MenuItem("Open Logs", self.open_logs),
1095
1254
  pystray.MenuItem("Open Data Folder", self.open_config_dir),
1255
+ pystray.MenuItem("Directory Filters", pystray.Menu(
1256
+ pystray.MenuItem("Edit Allow List", self.set_allow_dirs),
1257
+ pystray.MenuItem("Edit Deny List", self.set_deny_dirs),
1258
+ pystray.Menu.SEPARATOR,
1259
+ pystray.MenuItem("Clear Allow List", self.clear_allow_dirs),
1260
+ pystray.MenuItem("Clear Deny List", self.clear_deny_dirs),
1261
+ )),
1096
1262
  pystray.Menu.SEPARATOR,
1097
1263
  pystray.MenuItem("Clear Backlog", self.clear_backlog),
1098
1264
  pystray.MenuItem("Clear Cache", self.clear_cache),
@@ -1106,7 +1272,7 @@ class TrayAppBase(abc.ABC): # Inherit from ABC for abstract methods
1106
1272
  menu_items.append(pystray.MenuItem("More", pystray.Menu(
1107
1273
  pystray.MenuItem("Donate ❤️", self.open_donation_page),
1108
1274
  pystray.Menu.SEPARATOR,
1109
- pystray.MenuItem("Check for Updates", lambda: self.check_updates_thread() if hasattr(self, 'check_updates_thread') else None),
1275
+ pystray.MenuItem("Check for Updates", self.check_updates_thread),
1110
1276
  pystray.MenuItem("Help", self.show_help),
1111
1277
  pystray.MenuItem("About", self.show_about),
1112
1278
  ))) # --- Exit (always last, separated) ---
@@ -208,6 +208,17 @@ class AppIndicatorTray:
208
208
  threshold_item.set_submenu(threshold_submenu)
209
209
  scrobbling_submenu.append(threshold_item)
210
210
 
211
+ scrobbling_submenu.append(gtk_module.SeparatorMenuItem())
212
+
213
+ # Disable Notifications toggle
214
+ notifications_disabled = get_setting('disable_notifications', False)
215
+ notifications_item = gtk_module.CheckMenuItem(label="Disable Notifications")
216
+ notifications_item.set_active(notifications_disabled)
217
+ notifications_item.connect("activate", self._wrap_callback(self.app.toggle_notifications_disabled))
218
+ scrobbling_submenu.append(notifications_item)
219
+
220
+ scrobbling_submenu.append(gtk_module.SeparatorMenuItem())
221
+
211
222
  watch_history_item = gtk_module.MenuItem(label="Open Local Watch History")
212
223
  watch_history_item.connect("activate", self._wrap_callback(self.app.open_watch_history))
213
224
  scrobbling_submenu.append(watch_history_item)
@@ -251,6 +262,30 @@ class AppIndicatorTray:
251
262
  config_item = gtk_module.MenuItem(label="Open Data Folder")
252
263
  config_item.connect("activate", self._wrap_callback(self.app.open_config_dir))
253
264
  maintenance_submenu.append(config_item)
265
+
266
+ filters_item = gtk_module.MenuItem(label="Directory Filters")
267
+ filters_submenu = gtk_module.Menu()
268
+
269
+ allow_item = gtk_module.MenuItem(label="Edit Allow List")
270
+ allow_item.connect("activate", self._wrap_callback(self.app.set_allow_dirs))
271
+ filters_submenu.append(allow_item)
272
+
273
+ deny_item = gtk_module.MenuItem(label="Edit Deny List")
274
+ deny_item.connect("activate", self._wrap_callback(self.app.set_deny_dirs))
275
+ filters_submenu.append(deny_item)
276
+
277
+ filters_submenu.append(gtk_module.SeparatorMenuItem())
278
+
279
+ clear_allow_item = gtk_module.MenuItem(label="Clear Allow List")
280
+ clear_allow_item.connect("activate", self._wrap_callback(self.app.clear_allow_dirs))
281
+ filters_submenu.append(clear_allow_item)
282
+
283
+ clear_deny_item = gtk_module.MenuItem(label="Clear Deny List")
284
+ clear_deny_item.connect("activate", self._wrap_callback(self.app.clear_deny_dirs))
285
+ filters_submenu.append(clear_deny_item)
286
+
287
+ filters_item.set_submenu(filters_submenu)
288
+ maintenance_submenu.append(filters_item)
254
289
 
255
290
  maintenance_submenu.append(gtk_module.SeparatorMenuItem())
256
291
 
@@ -547,22 +582,7 @@ class TrayAppLinux(TrayAppBase):
547
582
  def show_about(self, _=None):
548
583
  """Show about dialog with Linux-specific implementation"""
549
584
  try:
550
- # Try to get version information
551
- version = "Unknown"
552
-
553
- try:
554
- import pkg_resources
555
- version = pkg_resources.get_distribution("simkl-mps").version
556
- except:
557
- pass
558
-
559
- # Build the about text
560
- about_text = f"""Media Player Scrobbler for SIMKL
561
- Version: {version}
562
- Author: kavin
563
- License: GNU GPL v3
564
-
565
- Automatically track and scrobble your media to SIMKL."""
585
+ about_text = self._build_about_text()
566
586
 
567
587
  # Try using zenity for a nicer dialog
568
588
  try:
@@ -885,9 +905,33 @@ Automatically track and scrobble your media to SIMKL."""
885
905
  logger.error("zenity command not found, even after 'which' check (unexpected).")
886
906
  self.show_notification("Error", "zenity command not found.")
887
907
  return None
908
+
909
+ def _ask_directory_filter_dialog(self, title: str, current_value: str, help_text: str) -> Optional[str]:
910
+ """Ask user for directory filters using zenity entry (comma/semicolon separated)."""
911
+ try:
912
+ if subprocess.run(['which', 'zenity'], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode != 0:
913
+ self.show_notification("Cannot Edit Filters", "zenity is not installed. Please install zenity.")
914
+ return None
915
+
916
+ initial_value = current_value.replace("\n", "; ") if current_value else ""
917
+ process = subprocess.run(
918
+ [
919
+ 'zenity', '--entry',
920
+ f'--title={title}',
921
+ f'--text={help_text}\nSeparate entries with commas or semicolons.',
922
+ f'--entry-text={initial_value}'
923
+ ],
924
+ capture_output=True,
925
+ text=True,
926
+ check=False
927
+ )
928
+
929
+ if process.returncode == 0:
930
+ return process.stdout.strip()
931
+ return None
888
932
  except Exception as e:
889
- logger.error(f"Error using zenity for threshold input: {e}", exc_info=True)
890
- self.show_notification("Error", f"Could not get custom threshold: {e}")
933
+ logger.error(f"Error using zenity for directory filters: {e}", exc_info=True)
934
+ self.show_notification("Error", f"Could not edit directory filters: {e}")
891
935
  return None
892
936
 
893
937
  # _set_preset_threshold is handled by base class
@@ -160,23 +160,7 @@ class TrayAppMac(TrayAppBase):
160
160
  def show_about(self, _=None):
161
161
  """Show application information on macOS"""
162
162
  try:
163
- # Try to get version information
164
- version = "Unknown"
165
-
166
- # Try to get from pkg_resources
167
- try:
168
- import pkg_resources
169
- version = pkg_resources.get_distribution("simkl-mps").version
170
- except:
171
- pass
172
-
173
- # Build the about text
174
- about_text = f"""Media Player Scrobbler for SIMKL
175
- Version: {version}
176
- Author: kavin
177
- License: GNU GPL v3
178
-
179
- Automatically track and scrobble your media to SIMKL."""
163
+ about_text = self._build_about_text()
180
164
 
181
165
  # Use AppleScript dialog on macOS
182
166
  escaped_text = about_text.replace('"', '\\"')
@@ -487,6 +471,30 @@ Automatically track and scrobble your media to SIMKL."""
487
471
  self.show_notification("Error", f"Could not get custom threshold: {e}")
488
472
  return None
489
473
 
474
+ def _ask_directory_filter_dialog(self, title: str, current_value: str, help_text: str) -> str | None:
475
+ """macOS-specific directory filter input using AppleScript dialog."""
476
+ try:
477
+ initial_value = current_value.replace("\n", "; ") if current_value else ""
478
+ escaped_help = help_text.replace('"', '\\"')
479
+ escaped_title = title.replace('"', '\\"')
480
+ escaped_value = initial_value.replace('"', '\\"')
481
+ cmd = f'''osascript -e '
482
+ set answer to text returned of (display dialog "{escaped_help} (comma or semicolon separated)" \
483
+ default answer "{escaped_value}" \
484
+ with title "{escaped_title}" \
485
+ buttons {{"Cancel", "OK"}} default button "OK")
486
+ return answer
487
+ ' '''
488
+
489
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
490
+ if result.returncode == 0 and result.stdout.strip():
491
+ return result.stdout.strip()
492
+ return None
493
+ except Exception as e:
494
+ logger.error(f"Error showing directory filter dialog: {e}", exc_info=True)
495
+ self.show_notification("Error", f"Could not edit directory filters: {e}")
496
+ return None
497
+
490
498
  def run_tray_app():
491
499
  """Run the application in tray mode"""
492
500
  try: