wrkmon 1.0.1__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.
@@ -0,0 +1,408 @@
1
+ """ASCII art generator for YouTube thumbnails - Enhanced with colors and blocks."""
2
+
3
+ import asyncio
4
+ import io
5
+ import logging
6
+ import urllib.request
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger("wrkmon.ascii_art")
11
+
12
+ # Character sets for different styles
13
+ CHARS_BLOCKS = "█▓▒░ " # Block characters (best for images)
14
+ CHARS_BLOCKS_DETAILED = "████▓▓▒▒░░ " # More gradients
15
+ CHARS_ASCII = "@%#*+=-:. " # Classic ASCII
16
+ CHARS_BRAILLE = "⣿⣷⣯⣟⡿⢿⣻⣽⣾⣶⣦⣤⣀⡀ " # Braille patterns
17
+
18
+ # Color palette for Rich markup (approximate colors)
19
+ COLOR_PALETTE = [
20
+ "#000000", "#1a1a1a", "#333333", "#4d4d4d", "#666666",
21
+ "#808080", "#999999", "#b3b3b3", "#cccccc", "#e6e6e6", "#ffffff"
22
+ ]
23
+
24
+ # Extended color palette for better color representation
25
+ EXTENDED_COLORS = {
26
+ (0, 0, 0): "black",
27
+ (255, 255, 255): "white",
28
+ (255, 0, 0): "red",
29
+ (0, 255, 0): "green",
30
+ (0, 0, 255): "blue",
31
+ (255, 255, 0): "yellow",
32
+ (255, 0, 255): "magenta",
33
+ (0, 255, 255): "cyan",
34
+ (128, 128, 128): "grey50",
35
+ (192, 192, 192): "grey74",
36
+ (128, 0, 0): "dark_red",
37
+ (0, 128, 0): "dark_green",
38
+ (0, 0, 128): "dark_blue",
39
+ (128, 128, 0): "olive",
40
+ (128, 0, 128): "purple",
41
+ (0, 128, 128): "teal",
42
+ }
43
+
44
+
45
+ def get_thumbnail_url(video_id: str, quality: str = "mqdefault") -> str:
46
+ """
47
+ Get YouTube thumbnail URL for a video.
48
+
49
+ Quality options:
50
+ - default: 120x90
51
+ - mqdefault: 320x180
52
+ - hqdefault: 480x360
53
+ - sddefault: 640x480
54
+ - maxresdefault: 1280x720
55
+ """
56
+ return f"https://img.youtube.com/vi/{video_id}/{quality}.jpg"
57
+
58
+
59
+ def download_thumbnail(video_id: str, quality: str = "mqdefault") -> Optional[bytes]:
60
+ """Download thumbnail image from YouTube."""
61
+ url = get_thumbnail_url(video_id, quality)
62
+ try:
63
+ req = urllib.request.Request(
64
+ url,
65
+ headers={"User-Agent": "Mozilla/5.0"}
66
+ )
67
+ with urllib.request.urlopen(req, timeout=5) as response:
68
+ return response.read()
69
+ except Exception as e:
70
+ logger.debug(f"Failed to download thumbnail: {e}")
71
+ return None
72
+
73
+
74
+ async def download_thumbnail_async(video_id: str, quality: str = "mqdefault") -> Optional[bytes]:
75
+ """Async version of download_thumbnail."""
76
+ return await asyncio.to_thread(download_thumbnail, video_id, quality)
77
+
78
+
79
+ def find_closest_color(r: int, g: int, b: int) -> str:
80
+ """Find the closest named color for Rich markup."""
81
+ min_dist = float('inf')
82
+ closest = "white"
83
+
84
+ for (cr, cg, cb), name in EXTENDED_COLORS.items():
85
+ dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
86
+ if dist < min_dist:
87
+ min_dist = dist
88
+ closest = name
89
+
90
+ return closest
91
+
92
+
93
+ def rgb_to_hex(r: int, g: int, b: int) -> str:
94
+ """Convert RGB to hex color."""
95
+ return f"#{r:02x}{g:02x}{b:02x}"
96
+
97
+
98
+ def image_to_ascii_blocks(
99
+ image_data: bytes,
100
+ width: int = 40,
101
+ colored: bool = True,
102
+ ) -> str:
103
+ """
104
+ Convert image to colored block ASCII art.
105
+
106
+ Uses half-block characters (▀▄) to achieve 2x vertical resolution
107
+ with foreground and background colors.
108
+ """
109
+ try:
110
+ from PIL import Image
111
+ except ImportError:
112
+ logger.warning("Pillow not installed")
113
+ return ""
114
+
115
+ try:
116
+ img = Image.open(io.BytesIO(image_data))
117
+
118
+ # Calculate dimensions (2 pixels per character vertically)
119
+ aspect_ratio = img.height / img.width
120
+ char_height = int(width * aspect_ratio * 0.5) # Half because we use half-blocks
121
+ pixel_height = char_height * 2
122
+
123
+ # Resize
124
+ img = img.resize((width, pixel_height))
125
+ img = img.convert("RGB")
126
+ pixels = list(img.getdata())
127
+
128
+ lines = []
129
+ for y in range(0, pixel_height, 2):
130
+ line = ""
131
+ for x in range(width):
132
+ # Top pixel
133
+ top_idx = y * width + x
134
+ # Bottom pixel
135
+ bot_idx = (y + 1) * width + x if (y + 1) < pixel_height else top_idx
136
+
137
+ top_r, top_g, top_b = pixels[top_idx]
138
+ bot_r, bot_g, bot_b = pixels[bot_idx]
139
+
140
+ if colored:
141
+ # Use half-block with fg (top) and bg (bottom) colors
142
+ top_hex = rgb_to_hex(top_r, top_g, top_b)
143
+ bot_hex = rgb_to_hex(bot_r, bot_g, bot_b)
144
+ line += f"[{top_hex} on {bot_hex}]▀[/]"
145
+ else:
146
+ # Grayscale block
147
+ avg = (top_r + top_g + top_b + bot_r + bot_g + bot_b) // 6
148
+ char_idx = int(avg / 256 * len(CHARS_BLOCKS))
149
+ char_idx = min(char_idx, len(CHARS_BLOCKS) - 1)
150
+ line += CHARS_BLOCKS[char_idx]
151
+
152
+ lines.append(line)
153
+
154
+ return "\n".join(lines)
155
+
156
+ except Exception as e:
157
+ logger.debug(f"Failed to convert image: {e}")
158
+ return ""
159
+
160
+
161
+ def image_to_ascii_simple(
162
+ image_data: bytes,
163
+ width: int = 40,
164
+ chars: str = CHARS_BLOCKS,
165
+ ) -> str:
166
+ """
167
+ Simple grayscale ASCII art conversion.
168
+ """
169
+ try:
170
+ from PIL import Image
171
+ except ImportError:
172
+ return ""
173
+
174
+ try:
175
+ img = Image.open(io.BytesIO(image_data))
176
+
177
+ aspect_ratio = img.height / img.width
178
+ height = int(width * aspect_ratio * 0.5)
179
+
180
+ img = img.resize((width, height))
181
+ img = img.convert("L") # Grayscale
182
+
183
+ pixels = list(img.getdata())
184
+
185
+ lines = []
186
+ for i in range(0, len(pixels), width):
187
+ line = ""
188
+ for pixel in pixels[i:i + width]:
189
+ char_idx = int((255 - pixel) / 256 * len(chars))
190
+ char_idx = min(char_idx, len(chars) - 1)
191
+ line += chars[char_idx]
192
+ lines.append(line)
193
+
194
+ return "\n".join(lines)
195
+
196
+ except Exception as e:
197
+ logger.debug(f"Failed to convert image: {e}")
198
+ return ""
199
+
200
+
201
+ def image_to_ascii_colored_simple(
202
+ image_data: bytes,
203
+ width: int = 40,
204
+ ) -> str:
205
+ """
206
+ Colored ASCII using full blocks with single color per character.
207
+ Simpler than half-blocks but still colorful.
208
+ """
209
+ try:
210
+ from PIL import Image
211
+ except ImportError:
212
+ return ""
213
+
214
+ try:
215
+ img = Image.open(io.BytesIO(image_data))
216
+
217
+ aspect_ratio = img.height / img.width
218
+ height = int(width * aspect_ratio * 0.5)
219
+
220
+ img = img.resize((width, height))
221
+ img = img.convert("RGB")
222
+
223
+ pixels = list(img.getdata())
224
+
225
+ lines = []
226
+ for i in range(0, len(pixels), width):
227
+ line = ""
228
+ for r, g, b in pixels[i:i + width]:
229
+ hex_color = rgb_to_hex(r, g, b)
230
+ line += f"[{hex_color}]█[/]"
231
+ lines.append(line)
232
+
233
+ return "\n".join(lines)
234
+
235
+ except Exception as e:
236
+ logger.debug(f"Failed to convert image: {e}")
237
+ return ""
238
+
239
+
240
+ def image_to_braille(
241
+ image_data: bytes,
242
+ width: int = 60,
243
+ threshold: int = 128,
244
+ ) -> str:
245
+ """
246
+ Convert image to braille pattern art.
247
+ Each braille character represents a 2x4 pixel grid.
248
+ """
249
+ try:
250
+ from PIL import Image
251
+ except ImportError:
252
+ return ""
253
+
254
+ try:
255
+ img = Image.open(io.BytesIO(image_data))
256
+
257
+ # Braille is 2 dots wide, 4 dots tall per character
258
+ char_width = width
259
+ aspect_ratio = img.height / img.width
260
+ char_height = int(char_width * aspect_ratio * 0.5)
261
+
262
+ pixel_width = char_width * 2
263
+ pixel_height = char_height * 4
264
+
265
+ img = img.resize((pixel_width, pixel_height))
266
+ img = img.convert("L") # Grayscale
267
+
268
+ pixels = list(img.getdata())
269
+
270
+ def get_pixel(x, y):
271
+ if 0 <= x < pixel_width and 0 <= y < pixel_height:
272
+ return pixels[y * pixel_width + x] < threshold
273
+ return False
274
+
275
+ # Braille dot positions
276
+ # 1 4
277
+ # 2 5
278
+ # 3 6
279
+ # 7 8
280
+ dot_map = [
281
+ (0, 0, 0x01), (1, 0, 0x08),
282
+ (0, 1, 0x02), (1, 1, 0x10),
283
+ (0, 2, 0x04), (1, 2, 0x20),
284
+ (0, 3, 0x40), (1, 3, 0x80),
285
+ ]
286
+
287
+ lines = []
288
+ for cy in range(char_height):
289
+ line = ""
290
+ for cx in range(char_width):
291
+ px = cx * 2
292
+ py = cy * 4
293
+
294
+ code = 0x2800 # Braille base
295
+ for dx, dy, bit in dot_map:
296
+ if get_pixel(px + dx, py + dy):
297
+ code |= bit
298
+
299
+ line += chr(code)
300
+ lines.append(line)
301
+
302
+ return "\n".join(lines)
303
+
304
+ except Exception as e:
305
+ logger.debug(f"Failed to convert image: {e}")
306
+ return ""
307
+
308
+
309
+ def video_thumbnail_to_ascii(
310
+ video_id: str,
311
+ width: int = 40,
312
+ quality: str = "mqdefault",
313
+ style: str = "colored_blocks", # colored_blocks, colored_simple, blocks, braille
314
+ ) -> str:
315
+ """
316
+ Download YouTube thumbnail and convert to ASCII art.
317
+
318
+ Styles:
319
+ - colored_blocks: Half-block characters with full color (best quality)
320
+ - colored_simple: Full blocks with color
321
+ - blocks: Grayscale block characters
322
+ - braille: Braille dot patterns (high detail, monochrome)
323
+ """
324
+ image_data = download_thumbnail(video_id, quality)
325
+ if not image_data:
326
+ return ""
327
+
328
+ if style == "colored_blocks":
329
+ return image_to_ascii_blocks(image_data, width=width, colored=True)
330
+ elif style == "colored_simple":
331
+ return image_to_ascii_colored_simple(image_data, width=width)
332
+ elif style == "braille":
333
+ return image_to_braille(image_data, width=width)
334
+ else: # blocks
335
+ return image_to_ascii_simple(image_data, width=width)
336
+
337
+
338
+ async def video_thumbnail_to_ascii_async(
339
+ video_id: str,
340
+ width: int = 40,
341
+ quality: str = "mqdefault",
342
+ style: str = "colored_blocks",
343
+ ) -> str:
344
+ """Async version of video_thumbnail_to_ascii."""
345
+ image_data = await download_thumbnail_async(video_id, quality)
346
+ if not image_data:
347
+ return ""
348
+
349
+ if style == "colored_blocks":
350
+ return await asyncio.to_thread(image_to_ascii_blocks, image_data, width, True)
351
+ elif style == "colored_simple":
352
+ return await asyncio.to_thread(image_to_ascii_colored_simple, image_data, width)
353
+ elif style == "braille":
354
+ return await asyncio.to_thread(image_to_braille, image_data, width)
355
+ else:
356
+ return await asyncio.to_thread(image_to_ascii_simple, image_data, width)
357
+
358
+
359
+ # Cache for thumbnails
360
+ _thumbnail_cache: dict[str, str] = {}
361
+ _cache_max_size = 50
362
+
363
+
364
+ def get_cached_ascii(video_id: str) -> Optional[str]:
365
+ """Get cached ASCII art for a video."""
366
+ return _thumbnail_cache.get(video_id)
367
+
368
+
369
+ def cache_ascii(video_id: str, ascii_art: str) -> None:
370
+ """Cache ASCII art for a video."""
371
+ global _thumbnail_cache
372
+
373
+ if len(_thumbnail_cache) >= _cache_max_size:
374
+ oldest = next(iter(_thumbnail_cache))
375
+ del _thumbnail_cache[oldest]
376
+
377
+ _thumbnail_cache[video_id] = ascii_art
378
+
379
+
380
+ def clear_cache() -> None:
381
+ """Clear the thumbnail cache."""
382
+ global _thumbnail_cache
383
+ _thumbnail_cache = {}
384
+
385
+
386
+ async def get_or_fetch_ascii(
387
+ video_id: str,
388
+ width: int = 40,
389
+ quality: str = "mqdefault",
390
+ style: str = "colored_blocks",
391
+ ) -> str:
392
+ """Get ASCII art from cache or fetch it."""
393
+ cache_key = f"{video_id}_{style}_{width}"
394
+ cached = _thumbnail_cache.get(cache_key)
395
+ if cached:
396
+ return cached
397
+
398
+ ascii_art = await video_thumbnail_to_ascii_async(
399
+ video_id,
400
+ width=width,
401
+ quality=quality,
402
+ style=style,
403
+ )
404
+
405
+ if ascii_art:
406
+ _thumbnail_cache[cache_key] = ascii_art
407
+
408
+ return ascii_art
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