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/__init__.py +1 -1
- wrkmon/app.py +323 -5
- wrkmon/cli.py +100 -0
- wrkmon/core/media_keys.py +377 -0
- wrkmon/core/queue.py +55 -0
- wrkmon/core/youtube.py +102 -0
- wrkmon/data/database.py +120 -0
- wrkmon/data/migrations.py +26 -0
- wrkmon/ui/screens/help.py +80 -0
- wrkmon/ui/theme.py +332 -75
- wrkmon/ui/views/search.py +283 -19
- wrkmon/ui/widgets/header.py +31 -1
- wrkmon/ui/widgets/player_bar.py +52 -5
- wrkmon/ui/widgets/thumbnail.py +230 -0
- wrkmon/utils/ascii_art.py +408 -0
- wrkmon/utils/config.py +85 -4
- wrkmon/utils/updater.py +311 -0
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/METADATA +170 -193
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/RECORD +23 -18
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/WHEEL +1 -1
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/entry_points.txt +0 -0
- {wrkmon-1.0.0.dist-info → wrkmon-1.2.0.dist-info}/top_level.txt +0 -0
- /wrkmon-1.0.0.dist-info/licenses/LICENSE.txt → /wrkmon-1.2.0.dist-info/licenses/LICENSE +0 -0
wrkmon/utils/config.py
CHANGED
|
@@ -22,7 +22,8 @@ class Config:
|
|
|
22
22
|
"general": {
|
|
23
23
|
"volume": 80,
|
|
24
24
|
"shuffle": False,
|
|
25
|
-
"
|
|
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": "
|
|
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
|
-
|
|
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
|
wrkmon/utils/updater.py
ADDED
|
@@ -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
|