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.
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/PKG-INFO +1 -12
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/README.md +0 -11
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/pyproject.toml +1 -1
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/config_manager.py +24 -1
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_scrobbler.py +53 -9
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/monitor.py +1 -1
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_base.py +169 -3
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_linux.py +62 -18
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_mac.py +25 -17
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_win.py +170 -119
- simkl_mps-2.3.1/simkl_mps/utils/path_filter.py +129 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/updater.ps1 +174 -23
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/LICENSE +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/__init__.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps (Custom).png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-128.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-16.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-24.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-256.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-32.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-48.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-64.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-error.ico +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-error.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-paused.ico +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-paused.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-running.ico +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-running.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-stopped.ico +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps-stopped.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps.ico +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/assets/simkl-mps.png +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/backlog_cleaner.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/cli.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/compatibility_patches.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/credentials.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/main.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_cache.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/media_tracker.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/migration.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/__init__.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpc.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpcqt.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpv.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/mpv_wrappers.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/potplayer.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/players/vlc.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/simkl_api.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/tray_app.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/__init__.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/constants.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/linux_tray_diagnostics.py +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/utils/updater.sh +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Bold.woff2 +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Medium.woff2 +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-Regular.woff2 +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts/Inter-SemiBold.woff2 +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/fonts.css +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/index.html +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/script.js +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch-history-viewer/style.css +0 -0
- {simkl_mps-2.2.2 → simkl_mps-2.3.1}/simkl_mps/watch_history_manager.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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 >
|
|
320
|
-
logger.warning(f"
|
|
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 >
|
|
359
|
-
logger.warning(f"
|
|
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.
|
|
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
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
890
|
-
self.show_notification("Error", f"Could not
|
|
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
|
-
|
|
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:
|