wrkmon 1.0.0__py3-none-any.whl → 1.2.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.
wrkmon/utils/config.py CHANGED
@@ -22,7 +22,8 @@ class Config:
22
22
  "general": {
23
23
  "volume": 80,
24
24
  "shuffle": False,
25
- "repeat": False,
25
+ "repeat_mode": "none", # none, one, all
26
+ "show_trending_on_start": True,
26
27
  },
27
28
  "player": {
28
29
  "mpv_path": "mpv",
@@ -34,8 +35,11 @@ class Config:
34
35
  "max_entries": 1000,
35
36
  },
36
37
  "ui": {
37
- "theme": "matrix", # matrix, minimal, hacker
38
+ "theme": "github_dark", # github_dark, matrix, dracula, nord
38
39
  "show_fake_stats": True,
40
+ "show_thumbnails": True,
41
+ "thumbnail_style": "colored_blocks", # colored_blocks, colored_simple, braille, blocks
42
+ "thumbnail_width": 45,
39
43
  },
40
44
  }
41
45
 
@@ -70,7 +74,9 @@ class Config:
70
74
 
71
75
  def _load(self) -> None:
72
76
  """Load configuration from file."""
73
- self._config = self.DEFAULT_CONFIG.copy()
77
+ # Deep copy default config
78
+ import copy
79
+ self._config = copy.deepcopy(self.DEFAULT_CONFIG)
74
80
 
75
81
  if self._config_file.exists() and tomllib is not None:
76
82
  try:
@@ -134,6 +140,7 @@ class Config:
134
140
  """Get cache file path."""
135
141
  return self._data_dir / "cache.db"
136
142
 
143
+ # General settings
137
144
  @property
138
145
  def volume(self) -> int:
139
146
  """Get current volume setting."""
@@ -144,6 +151,38 @@ class Config:
144
151
  """Set volume."""
145
152
  self.set("general", "volume", max(0, min(100, value)))
146
153
 
154
+ @property
155
+ def repeat_mode(self) -> str:
156
+ """Get repeat mode (none, one, all)."""
157
+ return self.get("general", "repeat_mode", "none")
158
+
159
+ @repeat_mode.setter
160
+ def repeat_mode(self, value: str) -> None:
161
+ """Set repeat mode."""
162
+ if value in ("none", "one", "all"):
163
+ self.set("general", "repeat_mode", value)
164
+
165
+ @property
166
+ def shuffle(self) -> bool:
167
+ """Get shuffle setting."""
168
+ return self.get("general", "shuffle", False)
169
+
170
+ @shuffle.setter
171
+ def shuffle(self, value: bool) -> None:
172
+ """Set shuffle."""
173
+ self.set("general", "shuffle", value)
174
+
175
+ @property
176
+ def show_trending_on_start(self) -> bool:
177
+ """Whether to show trending videos on startup."""
178
+ return self.get("general", "show_trending_on_start", True)
179
+
180
+ @show_trending_on_start.setter
181
+ def show_trending_on_start(self, value: bool) -> None:
182
+ """Set show trending on start."""
183
+ self.set("general", "show_trending_on_start", value)
184
+
185
+ # Player settings
147
186
  @property
148
187
  def mpv_path(self) -> str:
149
188
  """Get mpv executable path."""
@@ -151,14 +190,56 @@ class Config:
151
190
  configured = self.get("player", "mpv_path", "mpv")
152
191
  if configured != "mpv":
153
192
  return configured
154
- # Auto-detect mpv location
155
193
  return get_mpv_path()
156
194
 
195
+ # Cache settings
157
196
  @property
158
197
  def url_ttl_hours(self) -> int:
159
198
  """Get URL cache TTL in hours."""
160
199
  return self.get("cache", "url_ttl_hours", 6)
161
200
 
201
+ # UI settings
202
+ @property
203
+ def theme(self) -> str:
204
+ """Get UI theme."""
205
+ return self.get("ui", "theme", "github_dark")
206
+
207
+ @theme.setter
208
+ def theme(self, value: str) -> None:
209
+ """Set UI theme."""
210
+ self.set("ui", "theme", value)
211
+
212
+ @property
213
+ def show_thumbnails(self) -> bool:
214
+ """Whether to show thumbnails."""
215
+ return self.get("ui", "show_thumbnails", True)
216
+
217
+ @show_thumbnails.setter
218
+ def show_thumbnails(self, value: bool) -> None:
219
+ """Set show thumbnails."""
220
+ self.set("ui", "show_thumbnails", value)
221
+
222
+ @property
223
+ def thumbnail_style(self) -> str:
224
+ """Get thumbnail rendering style."""
225
+ return self.get("ui", "thumbnail_style", "colored_blocks")
226
+
227
+ @thumbnail_style.setter
228
+ def thumbnail_style(self, value: str) -> None:
229
+ """Set thumbnail style."""
230
+ if value in ("colored_blocks", "colored_simple", "braille", "blocks"):
231
+ self.set("ui", "thumbnail_style", value)
232
+
233
+ @property
234
+ def thumbnail_width(self) -> int:
235
+ """Get thumbnail width in characters."""
236
+ return self.get("ui", "thumbnail_width", 45)
237
+
238
+ @thumbnail_width.setter
239
+ def thumbnail_width(self, value: int) -> None:
240
+ """Set thumbnail width."""
241
+ self.set("ui", "thumbnail_width", max(20, min(80, value)))
242
+
162
243
 
163
244
  # Global config instance
164
245
  _config: Config | None = None
@@ -0,0 +1,311 @@
1
+ """Version checker and updater for wrkmon."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from packaging import version
14
+
15
+ from wrkmon import __version__
16
+
17
+ logger = logging.getLogger("wrkmon.updater")
18
+
19
+ PYPI_URL = "https://pypi.org/pypi/wrkmon/json"
20
+ CHECK_INTERVAL_HOURS = 24
21
+
22
+
23
+ @dataclass
24
+ class UpdateInfo:
25
+ """Information about an available update."""
26
+
27
+ current_version: str
28
+ latest_version: str
29
+ is_update_available: bool
30
+ release_url: str = "https://pypi.org/project/wrkmon/"
31
+
32
+ @property
33
+ def update_command(self) -> str:
34
+ """Get the command to update wrkmon."""
35
+ return "pip install --upgrade wrkmon"
36
+
37
+
38
+ def get_current_version() -> str:
39
+ """Get the current installed version."""
40
+ return __version__
41
+
42
+
43
+ def check_pypi_version() -> Optional[str]:
44
+ """
45
+ Check PyPI for the latest version of wrkmon.
46
+
47
+ Returns:
48
+ The latest version string, or None if check failed.
49
+ """
50
+ try:
51
+ req = urllib.request.Request(
52
+ PYPI_URL,
53
+ headers={"Accept": "application/json", "User-Agent": "wrkmon-updater"}
54
+ )
55
+ with urllib.request.urlopen(req, timeout=5) as response:
56
+ data = json.loads(response.read().decode())
57
+ return data.get("info", {}).get("version")
58
+ except Exception as e:
59
+ logger.debug(f"Failed to check PyPI version: {e}")
60
+ return None
61
+
62
+
63
+ async def check_pypi_version_async() -> Optional[str]:
64
+ """Async wrapper for checking PyPI version."""
65
+ return await asyncio.to_thread(check_pypi_version)
66
+
67
+
68
+ def compare_versions(current: str, latest: str) -> bool:
69
+ """
70
+ Check if an update is available.
71
+
72
+ Returns:
73
+ True if latest > current (update available).
74
+ """
75
+ try:
76
+ return version.parse(latest) > version.parse(current)
77
+ except Exception:
78
+ # Fallback to string comparison
79
+ return latest != current and latest > current
80
+
81
+
82
+ def check_for_updates() -> Optional[UpdateInfo]:
83
+ """
84
+ Check if a newer version is available on PyPI.
85
+
86
+ Returns:
87
+ UpdateInfo if check succeeded, None if check failed.
88
+ """
89
+ current = get_current_version()
90
+ latest = check_pypi_version()
91
+
92
+ if latest is None:
93
+ return None
94
+
95
+ return UpdateInfo(
96
+ current_version=current,
97
+ latest_version=latest,
98
+ is_update_available=compare_versions(current, latest),
99
+ )
100
+
101
+
102
+ async def check_for_updates_async() -> Optional[UpdateInfo]:
103
+ """Async version of check_for_updates."""
104
+ return await asyncio.to_thread(check_for_updates)
105
+
106
+
107
+ def perform_update() -> tuple[bool, str]:
108
+ """
109
+ Attempt to update wrkmon using pip.
110
+
111
+ Returns:
112
+ tuple: (success, message)
113
+ """
114
+ try:
115
+ result = subprocess.run(
116
+ [sys.executable, "-m", "pip", "install", "--upgrade", "wrkmon"],
117
+ capture_output=True,
118
+ text=True,
119
+ timeout=120,
120
+ )
121
+
122
+ if result.returncode == 0:
123
+ return True, "Update successful! Please restart wrkmon."
124
+ else:
125
+ return False, f"Update failed: {result.stderr}"
126
+ except subprocess.TimeoutExpired:
127
+ return False, "Update timed out. Try manually: pip install --upgrade wrkmon"
128
+ except Exception as e:
129
+ return False, f"Update error: {e}"
130
+
131
+
132
+ async def perform_update_async() -> tuple[bool, str]:
133
+ """Async version of perform_update."""
134
+ return await asyncio.to_thread(perform_update)
135
+
136
+
137
+ # ============================================================
138
+ # Dependency checkers for better compatibility
139
+ # ============================================================
140
+
141
+ def is_deno_installed() -> bool:
142
+ """Check if deno is installed."""
143
+ return shutil.which("deno") is not None
144
+
145
+
146
+ def is_nodejs_installed() -> bool:
147
+ """Check if Node.js is installed."""
148
+ return shutil.which("node") is not None
149
+
150
+
151
+ def get_js_runtime() -> Optional[str]:
152
+ """Get the available JavaScript runtime for yt-dlp."""
153
+ if is_deno_installed():
154
+ return "deno"
155
+ if is_nodejs_installed():
156
+ return "nodejs"
157
+ return None
158
+
159
+
160
+ def get_deno_install_command() -> str:
161
+ """Get the command to install deno for the current platform."""
162
+ if sys.platform == "win32":
163
+ return "irm https://deno.land/install.ps1 | iex"
164
+ else:
165
+ return "curl -fsSL https://deno.land/install.sh | sh"
166
+
167
+
168
+ def install_deno() -> tuple[bool, str]:
169
+ """
170
+ Attempt to install deno.
171
+
172
+ Returns:
173
+ tuple: (success, message)
174
+ """
175
+ if is_deno_installed():
176
+ return True, "deno is already installed"
177
+
178
+ try:
179
+ if sys.platform == "win32":
180
+ # Try winget first
181
+ try:
182
+ result = subprocess.run(
183
+ ["winget", "install", "--id", "DenoLand.Deno", "-e", "--silent"],
184
+ capture_output=True,
185
+ timeout=300,
186
+ )
187
+ if result.returncode == 0:
188
+ return True, "deno installed via winget"
189
+ except Exception:
190
+ pass
191
+
192
+ # Try scoop
193
+ try:
194
+ result = subprocess.run(
195
+ ["scoop", "install", "deno"],
196
+ capture_output=True,
197
+ timeout=300,
198
+ )
199
+ if result.returncode == 0:
200
+ return True, "deno installed via scoop"
201
+ except Exception:
202
+ pass
203
+
204
+ # Try chocolatey
205
+ try:
206
+ result = subprocess.run(
207
+ ["choco", "install", "deno", "-y"],
208
+ capture_output=True,
209
+ timeout=300,
210
+ )
211
+ if result.returncode == 0:
212
+ return True, "deno installed via chocolatey"
213
+ except Exception:
214
+ pass
215
+
216
+ return False, f"Please install deno manually:\n{get_deno_install_command()}"
217
+
218
+ else:
219
+ # Unix - try package managers first
220
+ if sys.platform == "darwin":
221
+ # macOS - try brew
222
+ try:
223
+ result = subprocess.run(
224
+ ["brew", "install", "deno"],
225
+ capture_output=True,
226
+ timeout=300,
227
+ )
228
+ if result.returncode == 0:
229
+ return True, "deno installed via homebrew"
230
+ except Exception:
231
+ pass
232
+ else:
233
+ # Linux - try snap
234
+ try:
235
+ result = subprocess.run(
236
+ ["snap", "install", "deno"],
237
+ capture_output=True,
238
+ timeout=300,
239
+ )
240
+ if result.returncode == 0:
241
+ return True, "deno installed via snap"
242
+ except Exception:
243
+ pass
244
+
245
+ # Try the official install script
246
+ try:
247
+ result = subprocess.run(
248
+ ["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"],
249
+ capture_output=True,
250
+ timeout=300,
251
+ )
252
+ if result.returncode == 0:
253
+ return True, "deno installed via official script"
254
+ except Exception:
255
+ pass
256
+
257
+ return False, f"Please install deno manually:\n{get_deno_install_command()}"
258
+
259
+ except Exception as e:
260
+ return False, f"Installation failed: {e}"
261
+
262
+
263
+ async def install_deno_async() -> tuple[bool, str]:
264
+ """Async version of install_deno."""
265
+ return await asyncio.to_thread(install_deno)
266
+
267
+
268
+ def check_dependencies() -> dict:
269
+ """
270
+ Check all optional dependencies for optimal functionality.
271
+
272
+ Returns:
273
+ dict with dependency status.
274
+ """
275
+ from wrkmon.utils.mpv_installer import is_mpv_installed
276
+
277
+ return {
278
+ "mpv": {
279
+ "installed": is_mpv_installed(),
280
+ "required": True,
281
+ "description": "Media player for audio playback",
282
+ },
283
+ "deno": {
284
+ "installed": is_deno_installed(),
285
+ "required": False,
286
+ "description": "JavaScript runtime for better YouTube compatibility",
287
+ },
288
+ "nodejs": {
289
+ "installed": is_nodejs_installed(),
290
+ "required": False,
291
+ "description": "Alternative JavaScript runtime",
292
+ },
293
+ "js_runtime": {
294
+ "installed": get_js_runtime() is not None,
295
+ "required": False,
296
+ "description": "JavaScript runtime (deno or nodejs) for full YouTube support",
297
+ },
298
+ }
299
+
300
+
301
+ def get_missing_dependencies() -> list[str]:
302
+ """Get list of missing recommended dependencies."""
303
+ deps = check_dependencies()
304
+ missing = []
305
+
306
+ if not deps["mpv"]["installed"]:
307
+ missing.append("mpv")
308
+ if not deps["js_runtime"]["installed"]:
309
+ missing.append("deno (recommended for YouTube)")
310
+
311
+ return missing