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
|
@@ -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
|