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.
- simkl_mps/__init__.py +19 -0
- simkl_mps/assets/simkl-mps-128.png +0 -0
- simkl_mps/assets/simkl-mps-16.png +0 -0
- simkl_mps/assets/simkl-mps-24.png +0 -0
- simkl_mps/assets/simkl-mps-256.png +0 -0
- simkl_mps/assets/simkl-mps-32.png +0 -0
- simkl_mps/assets/simkl-mps-48.png +0 -0
- simkl_mps/assets/simkl-mps-64.png +0 -0
- simkl_mps/assets/simkl-mps-error.ico +0 -0
- simkl_mps/assets/simkl-mps-error.png +0 -0
- simkl_mps/assets/simkl-mps-paused.ico +0 -0
- simkl_mps/assets/simkl-mps-paused.png +0 -0
- simkl_mps/assets/simkl-mps-running.ico +0 -0
- simkl_mps/assets/simkl-mps-running.png +0 -0
- simkl_mps/assets/simkl-mps-stopped.ico +0 -0
- simkl_mps/assets/simkl-mps-stopped.png +0 -0
- simkl_mps/assets/simkl-mps.ico +0 -0
- simkl_mps/assets/simkl-mps.png +0 -0
- simkl_mps/backlog_cleaner.py +100 -0
- simkl_mps/cli.py +482 -0
- simkl_mps/compatibility_patches.py +16 -0
- simkl_mps/credentials.py +111 -0
- simkl_mps/main.py +296 -0
- simkl_mps/media_cache.py +54 -0
- simkl_mps/media_tracker.py +122 -0
- simkl_mps/monitor.py +141 -0
- simkl_mps/movie_scrobbler.py +689 -0
- simkl_mps/players/__init__.py +17 -0
- simkl_mps/players/mpc.py +188 -0
- simkl_mps/players/mpv.py +602 -0
- simkl_mps/players/potplayer.py +300 -0
- simkl_mps/players/vlc.py +261 -0
- simkl_mps/simkl_api.py +412 -0
- simkl_mps/tray_app.py +903 -0
- simkl_mps/utils/__init__.py +5 -0
- simkl_mps/utils/constants.py +9 -0
- simkl_mps/utils/create_icns.py +174 -0
- simkl_mps/utils/updater.ps1 +467 -0
- simkl_mps/utils/updater.sh +368 -0
- simkl_mps/window_detection.py +570 -0
- simkl_mps-2.0.0.dist-info/LICENSE +674 -0
- simkl_mps-2.0.0.dist-info/METADATA +153 -0
- simkl_mps-2.0.0.dist-info/RECORD +45 -0
- simkl_mps-2.0.0.dist-info/WHEEL +4 -0
- 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
|