wrkmon 1.0.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 ADDED
@@ -0,0 +1,4 @@
1
+ """wrkmon - Work Monitor: A developer productivity tool."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "wrkmon"
wrkmon/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running wrkmon as a module."""
2
+
3
+ from wrkmon.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
wrkmon/app.py ADDED
@@ -0,0 +1,568 @@
1
+ """Main TUI application for wrkmon - properly structured with Textual best practices."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ # Setup logging to file
8
+ log_path = Path.home() / ".wrkmon_debug.log"
9
+ logging.basicConfig(
10
+ filename=str(log_path),
11
+ level=logging.DEBUG,
12
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
13
+ datefmt="%H:%M:%S",
14
+ )
15
+ logger = logging.getLogger("wrkmon.app")
16
+ logger.info(f"=== WRKMON STARTED === Log file: {log_path}")
17
+
18
+ from textual.app import App, ComposeResult
19
+ from textual.binding import Binding
20
+ from textual.containers import Container
21
+ from textual.widgets import Footer, ContentSwitcher
22
+
23
+ from wrkmon.core.youtube import YouTubeClient, SearchResult
24
+ from wrkmon.core.player import AudioPlayer
25
+ from wrkmon.core.queue import PlayQueue
26
+ from wrkmon.core.cache import Cache
27
+ from wrkmon.data.database import Database
28
+ from wrkmon.utils.config import get_config
29
+ from wrkmon.utils.stealth import get_stealth
30
+
31
+ from wrkmon.ui.theme import APP_CSS
32
+ from wrkmon.ui.widgets.header import HeaderBar
33
+ from wrkmon.ui.widgets.player_bar import PlayerBar
34
+ from wrkmon.ui.views.search import SearchView
35
+ from wrkmon.ui.views.queue import QueueView
36
+ from wrkmon.ui.views.history import HistoryView
37
+ from wrkmon.ui.views.playlists import PlaylistsView
38
+ from wrkmon.ui.messages import (
39
+ TrackSelected,
40
+ TrackQueued,
41
+ StatusMessage,
42
+ PlaybackStateChanged,
43
+ )
44
+
45
+
46
+ class WrkmonApp(App):
47
+ """Main wrkmon TUI application with proper Textual architecture."""
48
+
49
+ CSS = APP_CSS
50
+ TITLE = "wrkmon"
51
+
52
+ BINDINGS = [
53
+ # Global navigation (priority so they work even when input focused)
54
+ Binding("f1", "switch_view('search')", "Search", show=True, priority=True),
55
+ Binding("f2", "switch_view('queue')", "Queue", show=True, priority=True),
56
+ Binding("f3", "switch_view('history')", "History", show=True, priority=True),
57
+ Binding("f4", "switch_view('playlists')", "Lists", show=True, priority=True),
58
+ # Playback controls (global)
59
+ Binding("f5", "toggle_pause", "Play/Pause", show=True, priority=True),
60
+ Binding("f6", "volume_down", "Vol-", show=True, priority=True),
61
+ Binding("f7", "volume_up", "Vol+", show=True, priority=True),
62
+ Binding("f8", "next_track", "Next", show=True, priority=True),
63
+ Binding("f9", "stop", "Stop", show=True, priority=True),
64
+ Binding("f10", "queue_current", "Queue", show=True, priority=True),
65
+ # Additional controls (when not in input)
66
+ Binding("space", "toggle_pause", "Play/Pause", show=False),
67
+ Binding("+", "volume_up", "Vol+", show=False),
68
+ Binding("=", "volume_up", "Vol+", show=False),
69
+ Binding("-", "volume_down", "Vol-", show=False),
70
+ Binding("n", "next_track", "Next", show=False),
71
+ Binding("p", "prev_track", "Prev", show=False),
72
+ Binding("s", "stop", "Stop", show=False),
73
+ # App controls
74
+ Binding("escape", "focus_search", "Back", show=False, priority=True),
75
+ Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
76
+ Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
77
+ ]
78
+
79
+ def __init__(self, **kwargs) -> None:
80
+ super().__init__(**kwargs)
81
+
82
+ # Load config
83
+ self._config = get_config()
84
+ self._stealth = get_stealth()
85
+
86
+ # Core services
87
+ self.youtube = YouTubeClient()
88
+ self.player = AudioPlayer()
89
+ self.queue = PlayQueue()
90
+ self.cache = Cache()
91
+ self.database = Database()
92
+
93
+ # State
94
+ self._volume = self._config.volume
95
+ self._current_track: SearchResult | None = None
96
+ self._current_view = "search"
97
+
98
+ def compose(self) -> ComposeResult:
99
+ """Compose the application layout."""
100
+ # Header (docked top)
101
+ yield HeaderBar()
102
+
103
+ # Main content area with view switcher
104
+ with Container(id="content-area"):
105
+ with ContentSwitcher(initial="search"):
106
+ yield SearchView(id="search")
107
+ yield QueueView(id="queue")
108
+ yield HistoryView(id="history")
109
+ yield PlaylistsView(id="playlists")
110
+
111
+ # Player bar (docked bottom)
112
+ yield PlayerBar()
113
+
114
+ # Footer with key hints
115
+ yield Footer()
116
+
117
+ async def on_mount(self) -> None:
118
+ """Initialize app on mount."""
119
+ # Set terminal title
120
+ self._stealth.set_terminal_title("wrkmon")
121
+
122
+ # Check if mpv is available
123
+ from wrkmon.utils.mpv_installer import is_mpv_installed, ensure_mpv_installed
124
+
125
+ if not is_mpv_installed():
126
+ success, msg = ensure_mpv_installed()
127
+ if not success:
128
+ # Show error in player bar
129
+ player_bar = self._get_player_bar()
130
+ player_bar.update_playback(
131
+ title="mpv not found! Run: winget install mpv",
132
+ is_playing=False
133
+ )
134
+ self.notify(
135
+ "mpv is required for audio playback.\n"
136
+ "Install with: winget install mpv",
137
+ title="mpv Not Found",
138
+ severity="error",
139
+ timeout=10
140
+ )
141
+ else:
142
+ # Try to start player
143
+ await self.player.start()
144
+ else:
145
+ # Start the audio player
146
+ started = await self.player.start()
147
+ if not started:
148
+ self._get_player_bar().update_playback(
149
+ title="Failed to start mpv",
150
+ is_playing=False
151
+ )
152
+
153
+ # Set initial volume
154
+ if self.player.is_connected:
155
+ await self.player.set_volume(self._volume)
156
+ self._get_player_bar().set_volume(self._volume)
157
+
158
+ # Start periodic updates
159
+ self.set_interval(1.0, self._update_playback_display)
160
+
161
+ # Update header view indicator
162
+ self._get_header().set_view_name("search")
163
+
164
+ # ----------------------------------------
165
+ # Component getters
166
+ # ----------------------------------------
167
+ def _get_header(self) -> HeaderBar:
168
+ """Get the header bar widget."""
169
+ return self.query_one(HeaderBar)
170
+
171
+ def _get_player_bar(self) -> PlayerBar:
172
+ """Get the player bar widget."""
173
+ return self.query_one(PlayerBar)
174
+
175
+ def _get_content_switcher(self) -> ContentSwitcher:
176
+ """Get the content switcher."""
177
+ return self.query_one(ContentSwitcher)
178
+
179
+ # ----------------------------------------
180
+ # Message handlers
181
+ # ----------------------------------------
182
+ async def on_track_selected(self, message: TrackSelected) -> None:
183
+ """Handle track selection for playback."""
184
+ await self.play_track(message.result)
185
+
186
+ def on_track_queued(self, message: TrackQueued) -> None:
187
+ """Handle adding track to queue."""
188
+ logger.info(f"=== TrackQueued received: {message.result.title} ===")
189
+ pos = self.add_to_queue(message.result)
190
+ logger.info(f" Added at position: {pos}")
191
+ logger.info(f" Queue length now: {self.queue.length}")
192
+ logger.info(f" Queue current_index: {self.queue.current_index}")
193
+
194
+ def on_status_message(self, message: StatusMessage) -> None:
195
+ """Handle status messages (could show in a notification area)."""
196
+ # For now, just log or ignore
197
+ pass
198
+
199
+ # ----------------------------------------
200
+ # Playback methods
201
+ # ----------------------------------------
202
+ async def play_track(self, result: SearchResult) -> bool:
203
+ """Play a track from search result."""
204
+ logger.info(f"=== play_track called: {result.title} ===")
205
+ logger.info(f" video_id: {result.video_id}")
206
+
207
+ self._current_track = result
208
+ player_bar = self._get_player_bar()
209
+ player_bar.update_playback(title=f"Loading: {result.title[:30]}...", is_playing=False)
210
+
211
+ # Check cache first
212
+ cached = self.cache.get(result.video_id)
213
+ if cached:
214
+ audio_url = cached.audio_url
215
+ logger.info(f" Cache HIT, audio_url: {audio_url[:80]}...")
216
+ else:
217
+ logger.info(" Cache MISS, fetching stream URL...")
218
+ # Get stream URL
219
+ player_bar.update_playback(title=f"Fetching: {result.title[:30]}...")
220
+ stream_info = await self.youtube.get_stream_url(result.video_id)
221
+ if not stream_info:
222
+ logger.error(" FAILED to get stream URL!")
223
+ player_bar.update_playback(title="ERROR: Failed to get stream URL", is_playing=False)
224
+ return False
225
+
226
+ audio_url = stream_info.audio_url
227
+ logger.info(f" Got audio_url: {audio_url[:80]}...")
228
+
229
+ # Cache it
230
+ self.cache.set(
231
+ video_id=result.video_id,
232
+ title=result.title,
233
+ channel=result.channel,
234
+ duration=result.duration,
235
+ audio_url=audio_url,
236
+ )
237
+
238
+ # Check if player is connected
239
+ logger.info(f" player.is_connected: {self.player.is_connected}")
240
+ if not self.player.is_connected:
241
+ logger.info(" Starting player...")
242
+ player_bar.update_playback(title="Starting player...")
243
+ started = await self.player.start()
244
+ logger.info(f" player.start() returned: {started}")
245
+ if not started:
246
+ logger.error(" FAILED to start player!")
247
+ player_bar.update_playback(
248
+ title="ERROR: mpv not found! Install mpv first.",
249
+ is_playing=False
250
+ )
251
+ return False
252
+
253
+ # Play
254
+ logger.info(" Calling player.play()...")
255
+ player_bar.update_playback(title=f"Buffering: {result.title[:30]}...")
256
+ success = await self.player.play(audio_url)
257
+ logger.info(f" player.play() returned: {success}")
258
+
259
+ if success:
260
+ logger.info(" SUCCESS - audio should be playing!")
261
+ player_bar.update_playback(title=result.title, is_playing=True)
262
+
263
+ # Add to history
264
+ track = self.database.get_or_create_track(
265
+ video_id=result.video_id,
266
+ title=result.title,
267
+ channel=result.channel,
268
+ duration=result.duration,
269
+ )
270
+ self.database.add_to_history(track)
271
+
272
+ # Add to queue if empty
273
+ if self.queue.is_empty:
274
+ self.add_to_queue(result)
275
+ self.queue.jump_to(0)
276
+ else:
277
+ logger.error(" FAILED - player.play() returned False!")
278
+ player_bar.update_playback(
279
+ title="ERROR: Playback failed - check mpv installation",
280
+ is_playing=False
281
+ )
282
+
283
+ return success
284
+
285
+ def add_to_queue(self, result: SearchResult) -> int:
286
+ """Add a track to the queue."""
287
+ return self.queue.add_search_result(result)
288
+
289
+ async def toggle_pause(self) -> None:
290
+ """Toggle play/pause."""
291
+ await self.player.toggle_pause()
292
+ is_playing = self.player.is_playing
293
+ self._get_player_bar().is_playing = is_playing
294
+
295
+ async def set_volume(self, volume: int) -> None:
296
+ """Set volume level."""
297
+ self._volume = max(0, min(100, volume))
298
+ await self.player.set_volume(self._volume)
299
+ self._get_player_bar().set_volume(self._volume)
300
+
301
+ async def play_next(self) -> None:
302
+ """Play next track in queue."""
303
+ next_item = self.queue.next()
304
+ if next_item:
305
+ result = SearchResult(
306
+ video_id=next_item.video_id,
307
+ title=next_item.title,
308
+ channel=next_item.channel,
309
+ duration=next_item.duration,
310
+ view_count=0,
311
+ )
312
+ await self.play_track(result)
313
+
314
+ async def play_previous(self) -> None:
315
+ """Play previous track in queue."""
316
+ prev_item = self.queue.previous()
317
+ if prev_item:
318
+ result = SearchResult(
319
+ video_id=prev_item.video_id,
320
+ title=prev_item.title,
321
+ channel=prev_item.channel,
322
+ duration=prev_item.duration,
323
+ view_count=0,
324
+ )
325
+ await self.play_track(result)
326
+
327
+ # ----------------------------------------
328
+ # Periodic updates
329
+ # ----------------------------------------
330
+ async def _update_playback_display(self) -> None:
331
+ """Update the player bar with current playback position."""
332
+ if not self._current_track:
333
+ return
334
+
335
+ try:
336
+ player_bar = self._get_player_bar()
337
+
338
+ # Get current position and duration via IPC
339
+ pos = await self.player.get_position()
340
+ dur = await self.player.get_duration()
341
+ if dur == 0:
342
+ dur = self._current_track.duration
343
+ is_playing = self.player.is_playing
344
+
345
+ player_bar.update_playback(
346
+ position=pos,
347
+ duration=dur,
348
+ is_playing=is_playing,
349
+ )
350
+
351
+ # Update queue view if visible
352
+ if self._current_view == "queue":
353
+ queue_view = self.query_one("#queue", QueueView)
354
+ queue_view.update_now_playing(
355
+ self._current_track.title, pos, dur
356
+ )
357
+
358
+ # Check if track ended
359
+ if dur > 0 and pos >= dur - 1:
360
+ await self._on_track_end()
361
+
362
+ except Exception:
363
+ pass
364
+
365
+ async def _on_track_end(self) -> None:
366
+ """Handle track end - play next."""
367
+ next_item = self.queue.next()
368
+ if next_item:
369
+ result = SearchResult(
370
+ video_id=next_item.video_id,
371
+ title=next_item.title,
372
+ channel=next_item.channel,
373
+ duration=next_item.duration,
374
+ view_count=0,
375
+ )
376
+ await self.play_track(result)
377
+
378
+ # ----------------------------------------
379
+ # Actions
380
+ # ----------------------------------------
381
+ def action_switch_view(self, view_name: str) -> None:
382
+ """Switch to a different view."""
383
+ switcher = self._get_content_switcher()
384
+ switcher.current = view_name
385
+ self._current_view = view_name
386
+ self._get_header().set_view_name(view_name)
387
+
388
+ # Refresh queue view when switching to it
389
+ if view_name == "queue":
390
+ try:
391
+ self.query_one("#queue", QueueView).refresh_queue()
392
+ except Exception:
393
+ pass
394
+
395
+ async def action_toggle_pause(self) -> None:
396
+ """Smart play/pause - starts playback if nothing playing."""
397
+ logger.info("=== F5 PRESSED: action_toggle_pause ===")
398
+ logger.info(f" player.is_connected: {self.player.is_connected}")
399
+ logger.info(f" _current_track: {self._current_track}")
400
+ logger.info(f" queue.is_empty: {self.queue.is_empty}")
401
+ logger.info(f" queue.length: {self.queue.length}")
402
+ logger.info(f" queue.current: {self.queue.current}")
403
+
404
+ # If player is actively playing, just toggle pause
405
+ if self.player.is_connected and self._current_track:
406
+ logger.info(" -> Toggling pause (already playing)")
407
+ await self.toggle_pause()
408
+ return
409
+
410
+ # Nothing playing - try to play from queue
411
+ current = self.queue.current
412
+ if current:
413
+ logger.info(f" -> Playing current queue item: {current.title}")
414
+ # Play the current queue item
415
+ result = SearchResult(
416
+ video_id=current.video_id,
417
+ title=current.title,
418
+ channel=current.channel,
419
+ duration=current.duration,
420
+ view_count=0,
421
+ )
422
+ await self.play_track(result)
423
+ elif not self.queue.is_empty:
424
+ logger.info(" -> Queue has items, jumping to first")
425
+ # Queue has items but no current - start from first
426
+ self.queue.jump_to(0)
427
+ first = self.queue.current
428
+ if first:
429
+ logger.info(f" -> Playing first item: {first.title}")
430
+ result = SearchResult(
431
+ video_id=first.video_id,
432
+ title=first.title,
433
+ channel=first.channel,
434
+ duration=first.duration,
435
+ view_count=0,
436
+ )
437
+ await self.play_track(result)
438
+ else:
439
+ logger.warning(" -> Queue is EMPTY, cannot play")
440
+ # Queue is empty - notify user
441
+ self._get_player_bar().update_playback(
442
+ title="Queue empty - search and add tracks first",
443
+ is_playing=False
444
+ )
445
+
446
+ async def action_volume_up(self) -> None:
447
+ """Increase volume."""
448
+ await self.set_volume(self._volume + 5)
449
+
450
+ async def action_volume_down(self) -> None:
451
+ """Decrease volume."""
452
+ await self.set_volume(self._volume - 5)
453
+
454
+ async def action_next_track(self) -> None:
455
+ """Play next track."""
456
+ await self.play_next()
457
+
458
+ async def action_prev_track(self) -> None:
459
+ """Play previous track."""
460
+ await self.play_previous()
461
+
462
+ async def action_stop(self) -> None:
463
+ """Stop playback completely."""
464
+ logger.info("=== F9 PRESSED: action_stop ===")
465
+ await self.player.stop()
466
+ self._current_track = None
467
+ self._get_player_bar().update_playback(
468
+ title="Stopped",
469
+ is_playing=False,
470
+ position=0,
471
+ duration=0,
472
+ )
473
+ logger.info(" Playback stopped")
474
+
475
+ def action_queue_current(self) -> None:
476
+ """Queue the currently highlighted search result (F10)."""
477
+ logger.info("=== F10 PRESSED: action_queue_current ===")
478
+ if self._current_view != "search":
479
+ logger.info(" Not in search view, ignoring")
480
+ return
481
+
482
+ try:
483
+ search_view = self.query_one("#search", SearchView)
484
+ result = search_view._get_selected()
485
+ if result:
486
+ logger.info(f" Queueing: {result.title}")
487
+ pos = self.add_to_queue(result)
488
+ self.notify(f"Queued: {result.title[:30]}...", timeout=2)
489
+ logger.info(f" Added at position: {pos}, queue length: {self.queue.length}")
490
+ else:
491
+ logger.warning(" No item selected")
492
+ self.notify("Select a track first", severity="warning", timeout=2)
493
+ except Exception as e:
494
+ logger.exception(f" Error: {e}")
495
+
496
+ def action_focus_search(self) -> None:
497
+ """Switch to search view and focus input."""
498
+ self.action_switch_view("search")
499
+ try:
500
+ self.query_one("#search", SearchView).focus_input()
501
+ except Exception:
502
+ pass
503
+
504
+ async def action_quit(self) -> None:
505
+ """Quit the application cleanly."""
506
+ await self._cleanup()
507
+ self.exit()
508
+
509
+ async def _cleanup(self) -> None:
510
+ """Clean up resources."""
511
+ logger.info("=== Cleaning up ===")
512
+ # Save config
513
+ self._config.volume = self._volume
514
+ self._config.save()
515
+
516
+ # Shutdown player - MUST stop mpv
517
+ logger.info(" Stopping player...")
518
+ await self.player.shutdown()
519
+
520
+ # Close database
521
+ self.database.close()
522
+
523
+ # Restore terminal
524
+ self._stealth.restore_terminal_title()
525
+ logger.info(" Cleanup done")
526
+
527
+ async def on_unmount(self) -> None:
528
+ """Called when app is unmounting - ensure cleanup."""
529
+ await self._cleanup()
530
+
531
+
532
+ def run_app() -> None:
533
+ """Run the wrkmon application."""
534
+ import atexit
535
+ import signal
536
+
537
+ app = WrkmonApp()
538
+
539
+ def cleanup_on_exit():
540
+ """Ensure mpv is killed on exit."""
541
+ if app.player._process:
542
+ try:
543
+ app.player._process.terminate()
544
+ app.player._process.wait(timeout=1)
545
+ except Exception:
546
+ try:
547
+ app.player._process.kill()
548
+ except Exception:
549
+ pass
550
+
551
+ atexit.register(cleanup_on_exit)
552
+
553
+ # Handle Ctrl+C gracefully
554
+ def handle_sigint(signum, frame):
555
+ cleanup_on_exit()
556
+ raise SystemExit(0)
557
+
558
+ if sys.platform != "win32":
559
+ signal.signal(signal.SIGINT, handle_sigint)
560
+
561
+ try:
562
+ app.run()
563
+ finally:
564
+ cleanup_on_exit()
565
+
566
+
567
+ if __name__ == "__main__":
568
+ run_app()