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.
@@ -0,0 +1,230 @@
1
+ """ASCII thumbnail widget for wrkmon - with color support."""
2
+
3
+ import asyncio
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Container
6
+ from textual.reactive import reactive
7
+ from textual.widgets import Static
8
+
9
+ from wrkmon.utils.ascii_art import get_or_fetch_ascii, get_cached_ascii, clear_cache
10
+
11
+
12
+ class ThumbnailPreview(Static):
13
+ """Widget to display colored ASCII art thumbnail preview."""
14
+
15
+ DEFAULT_CSS = """
16
+ ThumbnailPreview {
17
+ width: 100%;
18
+ height: auto;
19
+ min-height: 8;
20
+ max-height: 18;
21
+ background: #0d1117;
22
+ padding: 0;
23
+ }
24
+
25
+ ThumbnailPreview.loading {
26
+ color: #6e7681;
27
+ }
28
+
29
+ ThumbnailPreview.hidden {
30
+ display: none;
31
+ }
32
+ """
33
+
34
+ video_id = reactive("")
35
+ is_loading = reactive(False)
36
+ style_mode = reactive("colored_blocks") # colored_blocks, colored_simple, blocks, braille
37
+
38
+ def __init__(
39
+ self,
40
+ video_id: str = "",
41
+ width: int = 45,
42
+ style: str = "colored_blocks",
43
+ **kwargs
44
+ ) -> None:
45
+ super().__init__(**kwargs)
46
+ self._ascii_width = width
47
+ self._current_task: asyncio.Task | None = None
48
+ self.style_mode = style
49
+ if video_id:
50
+ self.video_id = video_id
51
+
52
+ def compose(self) -> ComposeResult:
53
+ yield Static("", id="ascii-content", markup=True)
54
+
55
+ def on_mount(self) -> None:
56
+ """Load thumbnail on mount if video_id is set."""
57
+ if self.video_id:
58
+ self._load_thumbnail()
59
+
60
+ def watch_video_id(self, video_id: str) -> None:
61
+ """React to video_id changes."""
62
+ if video_id:
63
+ self._load_thumbnail()
64
+ else:
65
+ self._clear_thumbnail()
66
+
67
+ def _load_thumbnail(self) -> None:
68
+ """Load ASCII thumbnail for current video."""
69
+ if not self.video_id:
70
+ return
71
+
72
+ # Check cache first
73
+ cache_key = f"{self.video_id}_{self.style_mode}_{self._ascii_width}"
74
+ from wrkmon.utils.ascii_art import _thumbnail_cache
75
+ cached = _thumbnail_cache.get(cache_key)
76
+ if cached:
77
+ self._display_ascii(cached)
78
+ return
79
+
80
+ # Load async
81
+ self.is_loading = True
82
+ self.add_class("loading")
83
+ self._update_content("[dim]Loading...[/]")
84
+
85
+ # Cancel any existing task
86
+ if self._current_task and not self._current_task.done():
87
+ self._current_task.cancel()
88
+
89
+ # Start new load task
90
+ self._current_task = asyncio.create_task(self._fetch_and_display())
91
+
92
+ async def _fetch_and_display(self) -> None:
93
+ """Fetch thumbnail and display it."""
94
+ try:
95
+ ascii_art = await get_or_fetch_ascii(
96
+ self.video_id,
97
+ width=self._ascii_width,
98
+ style=self.style_mode,
99
+ )
100
+
101
+ if ascii_art:
102
+ self._display_ascii(ascii_art)
103
+ else:
104
+ self._update_content("[dim]No thumbnail[/]")
105
+
106
+ except asyncio.CancelledError:
107
+ pass
108
+ except Exception as e:
109
+ self._update_content(f"[red]Error: {e}[/]")
110
+ finally:
111
+ self.is_loading = False
112
+ self.remove_class("loading")
113
+
114
+ def _display_ascii(self, ascii_art: str) -> None:
115
+ """Display ASCII art in the widget."""
116
+ self._update_content(ascii_art)
117
+
118
+ def _update_content(self, content: str) -> None:
119
+ """Update the content display."""
120
+ try:
121
+ widget = self.query_one("#ascii-content", Static)
122
+ widget.update(content)
123
+ except Exception:
124
+ pass
125
+
126
+ def _clear_thumbnail(self) -> None:
127
+ """Clear the thumbnail display."""
128
+ self._update_content("")
129
+
130
+ def set_video(self, video_id: str) -> None:
131
+ """Set video ID and load thumbnail."""
132
+ self.video_id = video_id
133
+
134
+ def set_style(self, style: str) -> None:
135
+ """Change the rendering style and reload."""
136
+ if style != self.style_mode:
137
+ self.style_mode = style
138
+ if self.video_id:
139
+ self._load_thumbnail()
140
+
141
+ def clear(self) -> None:
142
+ """Clear the thumbnail."""
143
+ self.video_id = ""
144
+
145
+ def show(self) -> None:
146
+ """Show the widget."""
147
+ self.remove_class("hidden")
148
+
149
+ def hide(self) -> None:
150
+ """Hide the widget."""
151
+ self.add_class("hidden")
152
+
153
+ def cycle_style(self) -> str:
154
+ """Cycle through rendering styles."""
155
+ styles = ["colored_blocks", "colored_simple", "braille", "blocks"]
156
+ current_idx = styles.index(self.style_mode) if self.style_mode in styles else 0
157
+ next_idx = (current_idx + 1) % len(styles)
158
+ self.set_style(styles[next_idx])
159
+ return styles[next_idx]
160
+
161
+
162
+ class ThumbnailPanel(Container):
163
+ """Panel containing thumbnail preview with title."""
164
+
165
+ DEFAULT_CSS = """
166
+ ThumbnailPanel {
167
+ width: 100%;
168
+ height: auto;
169
+ background: #161b22;
170
+ border: solid #30363d;
171
+ padding: 1;
172
+ }
173
+
174
+ ThumbnailPanel > #panel-title {
175
+ height: 1;
176
+ color: #58a6ff;
177
+ text-style: bold;
178
+ margin-bottom: 1;
179
+ }
180
+
181
+ ThumbnailPanel.hidden {
182
+ display: none;
183
+ }
184
+ """
185
+
186
+ def __init__(self, title: str = "Preview", width: int = 45, **kwargs) -> None:
187
+ super().__init__(**kwargs)
188
+ self._title = title
189
+ self._width = width
190
+
191
+ def compose(self) -> ComposeResult:
192
+ yield Static(self._title, id="panel-title", markup=True)
193
+ yield ThumbnailPreview(width=self._width, id="thumbnail-preview")
194
+
195
+ def set_video(self, video_id: str, title: str = "") -> None:
196
+ """Set video to preview."""
197
+ if title:
198
+ try:
199
+ display_title = title[:40] + "..." if len(title) > 40 else title
200
+ self.query_one("#panel-title", Static).update(f"[bold]{display_title}[/]")
201
+ except Exception:
202
+ pass
203
+
204
+ try:
205
+ self.query_one("#thumbnail-preview", ThumbnailPreview).set_video(video_id)
206
+ except Exception:
207
+ pass
208
+
209
+ def clear(self) -> None:
210
+ """Clear the preview."""
211
+ try:
212
+ self.query_one("#thumbnail-preview", ThumbnailPreview).clear()
213
+ self.query_one("#panel-title", Static).update("Preview")
214
+ except Exception:
215
+ pass
216
+
217
+ def cycle_style(self) -> str:
218
+ """Cycle the thumbnail style."""
219
+ try:
220
+ return self.query_one("#thumbnail-preview", ThumbnailPreview).cycle_style()
221
+ except Exception:
222
+ return "colored_blocks"
223
+
224
+ def show(self) -> None:
225
+ """Show the panel."""
226
+ self.remove_class("hidden")
227
+
228
+ def hide(self) -> None:
229
+ """Hide the panel."""
230
+ self.add_class("hidden")
@@ -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