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/__init__.py
CHANGED
wrkmon/app.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Main TUI application for wrkmon - properly structured with Textual best practices."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
6
8
|
|
|
7
9
|
# Setup logging to file
|
|
8
10
|
log_path = Path.home() / ".wrkmon_debug.log"
|
|
@@ -41,6 +43,14 @@ from wrkmon.ui.messages import (
|
|
|
41
43
|
StatusMessage,
|
|
42
44
|
PlaybackStateChanged,
|
|
43
45
|
)
|
|
46
|
+
from wrkmon.ui.screens.help import HelpScreen
|
|
47
|
+
from wrkmon.utils.updater import (
|
|
48
|
+
check_for_updates_async,
|
|
49
|
+
check_dependencies,
|
|
50
|
+
get_js_runtime,
|
|
51
|
+
install_deno_async,
|
|
52
|
+
)
|
|
53
|
+
from wrkmon.core.media_keys import get_media_keys_handler, MediaKeysHandler
|
|
44
54
|
|
|
45
55
|
|
|
46
56
|
class WrkmonApp(App):
|
|
@@ -56,12 +66,12 @@ class WrkmonApp(App):
|
|
|
56
66
|
Binding("f3", "switch_view('history')", "History", show=True, priority=True),
|
|
57
67
|
Binding("f4", "switch_view('playlists')", "Lists", show=True, priority=True),
|
|
58
68
|
# Playback controls (global)
|
|
59
|
-
Binding("f5", "toggle_pause", "
|
|
69
|
+
Binding("f5", "toggle_pause", "▶/⏸", show=True, priority=True),
|
|
60
70
|
Binding("f6", "volume_down", "Vol-", show=True, priority=True),
|
|
61
71
|
Binding("f7", "volume_up", "Vol+", show=True, priority=True),
|
|
62
72
|
Binding("f8", "next_track", "Next", show=True, priority=True),
|
|
63
73
|
Binding("f9", "stop", "Stop", show=True, priority=True),
|
|
64
|
-
Binding("f10", "queue_current", "Queue", show=True, priority=True),
|
|
74
|
+
Binding("f10", "queue_current", "+Queue", show=True, priority=True),
|
|
65
75
|
# Additional controls (when not in input)
|
|
66
76
|
Binding("space", "toggle_pause", "Play/Pause", show=False),
|
|
67
77
|
Binding("+", "volume_up", "Vol+", show=False),
|
|
@@ -70,6 +80,14 @@ class WrkmonApp(App):
|
|
|
70
80
|
Binding("n", "next_track", "Next", show=False),
|
|
71
81
|
Binding("p", "prev_track", "Prev", show=False),
|
|
72
82
|
Binding("s", "stop", "Stop", show=False),
|
|
83
|
+
Binding("m", "toggle_mute", "Mute", show=False),
|
|
84
|
+
# Vim-style navigation
|
|
85
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
86
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
87
|
+
Binding("g", "cursor_top", "Top", show=False),
|
|
88
|
+
Binding("G", "cursor_bottom", "Bottom", show=False, key_display="shift+g"),
|
|
89
|
+
# Help
|
|
90
|
+
Binding("?", "show_help", "Help", show=True, priority=True),
|
|
73
91
|
# App controls
|
|
74
92
|
Binding("escape", "focus_search", "Back", show=False, priority=True),
|
|
75
93
|
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
@@ -95,6 +113,13 @@ class WrkmonApp(App):
|
|
|
95
113
|
self._current_track: SearchResult | None = None
|
|
96
114
|
self._current_view = "search"
|
|
97
115
|
|
|
116
|
+
# Restore playback settings from config
|
|
117
|
+
self.queue.repeat_mode = self._config.repeat_mode
|
|
118
|
+
self.queue.shuffle_mode = self._config.shuffle
|
|
119
|
+
|
|
120
|
+
# Media keys handler (for Fn+media buttons)
|
|
121
|
+
self._media_keys: Optional[MediaKeysHandler] = None
|
|
122
|
+
|
|
98
123
|
def compose(self) -> ComposeResult:
|
|
99
124
|
"""Compose the application layout."""
|
|
100
125
|
# Header (docked top)
|
|
@@ -119,6 +144,12 @@ class WrkmonApp(App):
|
|
|
119
144
|
# Set terminal title
|
|
120
145
|
self._stealth.set_terminal_title("wrkmon")
|
|
121
146
|
|
|
147
|
+
# Check for updates in background
|
|
148
|
+
self._check_for_updates_task = asyncio.create_task(self._check_for_updates())
|
|
149
|
+
|
|
150
|
+
# Check dependencies
|
|
151
|
+
await self._check_dependencies()
|
|
152
|
+
|
|
122
153
|
# Check if mpv is available
|
|
123
154
|
from wrkmon.utils.mpv_installer import is_mpv_installed, ensure_mpv_installed
|
|
124
155
|
|
|
@@ -161,6 +192,134 @@ class WrkmonApp(App):
|
|
|
161
192
|
# Update header view indicator
|
|
162
193
|
self._get_header().set_view_name("search")
|
|
163
194
|
|
|
195
|
+
# Start media keys handler (for Fn+Play/Pause, Next, Previous)
|
|
196
|
+
await self._start_media_keys()
|
|
197
|
+
|
|
198
|
+
# Load saved queue
|
|
199
|
+
self._load_saved_queue()
|
|
200
|
+
|
|
201
|
+
def _load_saved_queue(self) -> None:
|
|
202
|
+
"""Load the saved queue from database."""
|
|
203
|
+
try:
|
|
204
|
+
items, current_index, shuffle_mode, repeat_mode = self.database.load_queue()
|
|
205
|
+
if items:
|
|
206
|
+
self.queue.load_from_dicts(items, current_index)
|
|
207
|
+
self.queue.shuffle_mode = shuffle_mode
|
|
208
|
+
self.queue.repeat_mode = repeat_mode
|
|
209
|
+
logger.info(f"Loaded {len(items)} items from saved queue, index={current_index}")
|
|
210
|
+
|
|
211
|
+
# Show notification about restored queue
|
|
212
|
+
if len(items) > 0:
|
|
213
|
+
current = self.queue.current
|
|
214
|
+
if current:
|
|
215
|
+
pos_str = f" @ {current.playback_position // 60}:{current.playback_position % 60:02d}" if current.playback_position > 0 else ""
|
|
216
|
+
self.notify(
|
|
217
|
+
f"Queue restored: {len(items)} tracks\n"
|
|
218
|
+
f"Current: {current.title[:30]}...{pos_str}",
|
|
219
|
+
timeout=4
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.debug(f"Failed to load saved queue: {e}")
|
|
223
|
+
|
|
224
|
+
def _save_queue(self) -> None:
|
|
225
|
+
"""Save the current queue to database."""
|
|
226
|
+
try:
|
|
227
|
+
items = self.queue.to_dict_list()
|
|
228
|
+
self.database.save_queue(
|
|
229
|
+
items=items,
|
|
230
|
+
current_index=self.queue.current_index,
|
|
231
|
+
shuffle_mode=self.queue.shuffle_mode,
|
|
232
|
+
repeat_mode=self.queue.repeat_mode,
|
|
233
|
+
)
|
|
234
|
+
logger.info(f"Saved {len(items)} items to queue")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.debug(f"Failed to save queue: {e}")
|
|
237
|
+
|
|
238
|
+
async def _check_for_updates(self) -> None:
|
|
239
|
+
"""Check for updates in background."""
|
|
240
|
+
try:
|
|
241
|
+
update_info = await check_for_updates_async()
|
|
242
|
+
if update_info and update_info.is_update_available:
|
|
243
|
+
self._get_header().set_update_info(
|
|
244
|
+
available=True,
|
|
245
|
+
version=update_info.latest_version
|
|
246
|
+
)
|
|
247
|
+
self.notify(
|
|
248
|
+
f"New version {update_info.latest_version} available!\n"
|
|
249
|
+
f"Run: {update_info.update_command}",
|
|
250
|
+
title="Update Available",
|
|
251
|
+
timeout=8
|
|
252
|
+
)
|
|
253
|
+
logger.info(f"Update available: {update_info.latest_version}")
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.debug(f"Update check failed: {e}")
|
|
256
|
+
|
|
257
|
+
async def _check_dependencies(self) -> None:
|
|
258
|
+
"""Check optional dependencies and notify user."""
|
|
259
|
+
try:
|
|
260
|
+
js_runtime = get_js_runtime()
|
|
261
|
+
if not js_runtime:
|
|
262
|
+
# No JavaScript runtime - suggest installing deno
|
|
263
|
+
self.notify(
|
|
264
|
+
"Install deno for better YouTube compatibility:\n"
|
|
265
|
+
"curl -fsSL https://deno.land/install.sh | sh",
|
|
266
|
+
title="Tip: Install deno",
|
|
267
|
+
timeout=6
|
|
268
|
+
)
|
|
269
|
+
logger.info("No JavaScript runtime found, suggesting deno installation")
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.debug(f"Dependency check failed: {e}")
|
|
272
|
+
|
|
273
|
+
async def _start_media_keys(self) -> None:
|
|
274
|
+
"""Start media keys handler for Fn+media buttons."""
|
|
275
|
+
try:
|
|
276
|
+
self._media_keys = get_media_keys_handler(self._handle_media_key)
|
|
277
|
+
if self._media_keys and self._media_keys.is_available:
|
|
278
|
+
started = await self._media_keys.start()
|
|
279
|
+
if started:
|
|
280
|
+
logger.info(f"Media keys enabled via {self._media_keys.backend_name}")
|
|
281
|
+
self.notify(
|
|
282
|
+
f"Media keys active ({self._media_keys.backend_name})",
|
|
283
|
+
timeout=3
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
logger.info("Media keys handler failed to start")
|
|
287
|
+
else:
|
|
288
|
+
logger.info("Media keys not available on this platform")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.debug(f"Failed to start media keys: {e}")
|
|
291
|
+
|
|
292
|
+
async def _handle_media_key(self, command: str, *args) -> None:
|
|
293
|
+
"""Handle media key commands from OS."""
|
|
294
|
+
logger.info(f"Media key: {command} {args}")
|
|
295
|
+
try:
|
|
296
|
+
if command == "play_pause":
|
|
297
|
+
await self.action_toggle_pause()
|
|
298
|
+
elif command == "play":
|
|
299
|
+
if not self.player.is_playing:
|
|
300
|
+
await self.action_toggle_pause()
|
|
301
|
+
elif command == "pause":
|
|
302
|
+
if self.player.is_playing:
|
|
303
|
+
await self.toggle_pause()
|
|
304
|
+
elif command == "stop":
|
|
305
|
+
await self.action_stop()
|
|
306
|
+
elif command == "next":
|
|
307
|
+
await self.action_next_track()
|
|
308
|
+
elif command == "previous":
|
|
309
|
+
await self.action_prev_track()
|
|
310
|
+
elif command == "volume_up":
|
|
311
|
+
await self.action_volume_up()
|
|
312
|
+
elif command == "volume_down":
|
|
313
|
+
await self.action_volume_down()
|
|
314
|
+
elif command == "mute":
|
|
315
|
+
await self.action_toggle_mute()
|
|
316
|
+
elif command == "set_volume" and args:
|
|
317
|
+
await self.set_volume(args[0])
|
|
318
|
+
elif command == "quit":
|
|
319
|
+
await self.action_quit()
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(f"Error handling media key {command}: {e}")
|
|
322
|
+
|
|
164
323
|
# ----------------------------------------
|
|
165
324
|
# Component getters
|
|
166
325
|
# ----------------------------------------
|
|
@@ -258,8 +417,28 @@ class WrkmonApp(App):
|
|
|
258
417
|
|
|
259
418
|
if success:
|
|
260
419
|
logger.info(" SUCCESS - audio should be playing!")
|
|
420
|
+
|
|
421
|
+
# Check if we should resume from a saved position
|
|
422
|
+
saved_position = self.queue.get_playback_position(result.video_id)
|
|
423
|
+
if saved_position > 5: # Only resume if > 5 seconds in
|
|
424
|
+
logger.info(f" Resuming from saved position: {saved_position}s")
|
|
425
|
+
player_bar.update_playback(title=f"Resuming: {result.title[:30]}...")
|
|
426
|
+
await asyncio.sleep(0.5) # Give mpv time to load
|
|
427
|
+
await self.player.seek(saved_position, relative=False) # Absolute seek
|
|
428
|
+
|
|
261
429
|
player_bar.update_playback(title=result.title, is_playing=True)
|
|
262
430
|
|
|
431
|
+
# Update media keys metadata (for MPRIS/system media controls)
|
|
432
|
+
if self._media_keys:
|
|
433
|
+
self._media_keys.update_track(
|
|
434
|
+
title=result.title,
|
|
435
|
+
artist=result.channel,
|
|
436
|
+
duration=result.duration,
|
|
437
|
+
art_url=result.thumbnail_url or "",
|
|
438
|
+
track_id=result.video_id,
|
|
439
|
+
)
|
|
440
|
+
self._media_keys.update_playback(is_playing=True)
|
|
441
|
+
|
|
263
442
|
# Add to history
|
|
264
443
|
track = self.database.get_or_create_track(
|
|
265
444
|
video_id=result.video_id,
|
|
@@ -329,12 +508,18 @@ class WrkmonApp(App):
|
|
|
329
508
|
# ----------------------------------------
|
|
330
509
|
async def _update_playback_display(self) -> None:
|
|
331
510
|
"""Update the player bar with current playback position."""
|
|
511
|
+
player_bar = self._get_player_bar()
|
|
512
|
+
|
|
513
|
+
# Always sync repeat mode to player bar
|
|
514
|
+
try:
|
|
515
|
+
player_bar.repeat_mode = self.queue.repeat_mode
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
|
|
332
519
|
if not self._current_track:
|
|
333
520
|
return
|
|
334
521
|
|
|
335
522
|
try:
|
|
336
|
-
player_bar = self._get_player_bar()
|
|
337
|
-
|
|
338
523
|
# Get current position and duration via IPC
|
|
339
524
|
pos = await self.player.get_position()
|
|
340
525
|
dur = await self.player.get_duration()
|
|
@@ -348,6 +533,18 @@ class WrkmonApp(App):
|
|
|
348
533
|
is_playing=is_playing,
|
|
349
534
|
)
|
|
350
535
|
|
|
536
|
+
# Save playback position every 10 seconds (to reduce DB writes)
|
|
537
|
+
if is_playing and int(pos) % 10 == 0 and int(pos) > 0:
|
|
538
|
+
self.queue.update_playback_position(self._current_track.video_id, int(pos))
|
|
539
|
+
|
|
540
|
+
# Update media keys state (for MPRIS seekbar, etc.)
|
|
541
|
+
if self._media_keys:
|
|
542
|
+
self._media_keys.update_playback(
|
|
543
|
+
is_playing=is_playing,
|
|
544
|
+
position=pos,
|
|
545
|
+
volume=self._volume,
|
|
546
|
+
)
|
|
547
|
+
|
|
351
548
|
# Update queue view if visible
|
|
352
549
|
if self._current_view == "queue":
|
|
353
550
|
queue_view = self.query_one("#queue", QueueView)
|
|
@@ -391,6 +588,12 @@ class WrkmonApp(App):
|
|
|
391
588
|
self.query_one("#queue", QueueView).refresh_queue()
|
|
392
589
|
except Exception:
|
|
393
590
|
pass
|
|
591
|
+
# Auto-focus list when switching to search (if has results)
|
|
592
|
+
elif view_name == "search":
|
|
593
|
+
try:
|
|
594
|
+
self.query_one("#search", SearchView).focus_list()
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
394
597
|
|
|
395
598
|
async def action_toggle_pause(self) -> None:
|
|
396
599
|
"""Smart play/pause - starts playback if nothing playing."""
|
|
@@ -407,6 +610,18 @@ class WrkmonApp(App):
|
|
|
407
610
|
await self.toggle_pause()
|
|
408
611
|
return
|
|
409
612
|
|
|
613
|
+
# Nothing playing - if in search view with selected item, play it
|
|
614
|
+
if self._current_view == "search":
|
|
615
|
+
try:
|
|
616
|
+
search_view = self.query_one("#search", SearchView)
|
|
617
|
+
result = search_view._get_selected()
|
|
618
|
+
if result:
|
|
619
|
+
logger.info(f" -> Playing selected search result: {result.title}")
|
|
620
|
+
await self.play_track(result)
|
|
621
|
+
return
|
|
622
|
+
except Exception:
|
|
623
|
+
pass
|
|
624
|
+
|
|
410
625
|
# Nothing playing - try to play from queue
|
|
411
626
|
current = self.queue.current
|
|
412
627
|
if current:
|
|
@@ -501,6 +716,89 @@ class WrkmonApp(App):
|
|
|
501
716
|
except Exception:
|
|
502
717
|
pass
|
|
503
718
|
|
|
719
|
+
def action_show_help(self) -> None:
|
|
720
|
+
"""Show the help screen."""
|
|
721
|
+
self.push_screen(HelpScreen())
|
|
722
|
+
|
|
723
|
+
async def action_toggle_mute(self) -> None:
|
|
724
|
+
"""Toggle mute."""
|
|
725
|
+
if not hasattr(self, '_muted'):
|
|
726
|
+
self._muted = False
|
|
727
|
+
self._pre_mute_volume = self._volume
|
|
728
|
+
|
|
729
|
+
if self._muted:
|
|
730
|
+
# Unmute - restore previous volume
|
|
731
|
+
await self.set_volume(self._pre_mute_volume)
|
|
732
|
+
self._muted = False
|
|
733
|
+
self.notify("Unmuted", timeout=1)
|
|
734
|
+
else:
|
|
735
|
+
# Mute - save current volume and set to 0
|
|
736
|
+
self._pre_mute_volume = self._volume
|
|
737
|
+
await self.set_volume(0)
|
|
738
|
+
self._muted = True
|
|
739
|
+
self.notify("Muted", timeout=1)
|
|
740
|
+
|
|
741
|
+
def action_cursor_down(self) -> None:
|
|
742
|
+
"""Move cursor down in current list (vim j key)."""
|
|
743
|
+
self._navigate_list(1)
|
|
744
|
+
|
|
745
|
+
def action_cursor_up(self) -> None:
|
|
746
|
+
"""Move cursor up in current list (vim k key)."""
|
|
747
|
+
self._navigate_list(-1)
|
|
748
|
+
|
|
749
|
+
def action_cursor_top(self) -> None:
|
|
750
|
+
"""Jump to top of current list (vim g key)."""
|
|
751
|
+
self._navigate_list_to(0)
|
|
752
|
+
|
|
753
|
+
def action_cursor_bottom(self) -> None:
|
|
754
|
+
"""Jump to bottom of current list (vim G key)."""
|
|
755
|
+
self._navigate_list_to(-1)
|
|
756
|
+
|
|
757
|
+
def _navigate_list(self, delta: int) -> None:
|
|
758
|
+
"""Navigate in the current view's list."""
|
|
759
|
+
try:
|
|
760
|
+
if self._current_view == "search":
|
|
761
|
+
list_view = self.query_one("#results-list")
|
|
762
|
+
elif self._current_view == "queue":
|
|
763
|
+
list_view = self.query_one("#queue-list")
|
|
764
|
+
elif self._current_view == "history":
|
|
765
|
+
list_view = self.query_one("#history-list")
|
|
766
|
+
elif self._current_view == "playlists":
|
|
767
|
+
list_view = self.query_one("#playlist-list")
|
|
768
|
+
else:
|
|
769
|
+
return
|
|
770
|
+
|
|
771
|
+
if list_view and hasattr(list_view, 'index'):
|
|
772
|
+
new_index = max(0, list_view.index + delta)
|
|
773
|
+
if hasattr(list_view, 'children'):
|
|
774
|
+
new_index = min(new_index, len(list_view.children) - 1)
|
|
775
|
+
list_view.index = new_index
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
def _navigate_list_to(self, index: int) -> None:
|
|
780
|
+
"""Navigate to specific index in current list."""
|
|
781
|
+
try:
|
|
782
|
+
if self._current_view == "search":
|
|
783
|
+
list_view = self.query_one("#results-list")
|
|
784
|
+
elif self._current_view == "queue":
|
|
785
|
+
list_view = self.query_one("#queue-list")
|
|
786
|
+
elif self._current_view == "history":
|
|
787
|
+
list_view = self.query_one("#history-list")
|
|
788
|
+
elif self._current_view == "playlists":
|
|
789
|
+
list_view = self.query_one("#playlist-list")
|
|
790
|
+
else:
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
if list_view and hasattr(list_view, 'index'):
|
|
794
|
+
if index == -1 and hasattr(list_view, 'children'):
|
|
795
|
+
# Go to last item
|
|
796
|
+
list_view.index = max(0, len(list_view.children) - 1)
|
|
797
|
+
else:
|
|
798
|
+
list_view.index = index
|
|
799
|
+
except Exception:
|
|
800
|
+
pass
|
|
801
|
+
|
|
504
802
|
async def action_quit(self) -> None:
|
|
505
803
|
"""Quit the application cleanly."""
|
|
506
804
|
await self._cleanup()
|
|
@@ -509,9 +807,29 @@ class WrkmonApp(App):
|
|
|
509
807
|
async def _cleanup(self) -> None:
|
|
510
808
|
"""Clean up resources."""
|
|
511
809
|
logger.info("=== Cleaning up ===")
|
|
512
|
-
|
|
810
|
+
|
|
811
|
+
# Save current playback position before cleanup
|
|
812
|
+
if self._current_track:
|
|
813
|
+
try:
|
|
814
|
+
pos = await self.player.get_position()
|
|
815
|
+
self.queue.update_playback_position(self._current_track.video_id, int(pos))
|
|
816
|
+
logger.info(f" Saved position {int(pos)}s for {self._current_track.title[:30]}")
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
# Save queue to database
|
|
821
|
+
self._save_queue()
|
|
822
|
+
|
|
823
|
+
# Save all settings to config
|
|
513
824
|
self._config.volume = self._volume
|
|
825
|
+
self._config.repeat_mode = self.queue.repeat_mode
|
|
826
|
+
self._config.shuffle = self.queue.shuffle_mode
|
|
514
827
|
self._config.save()
|
|
828
|
+
logger.info(f" Saved settings: vol={self._volume}, repeat={self.queue.repeat_mode}, shuffle={self.queue.shuffle_mode}")
|
|
829
|
+
|
|
830
|
+
# Stop media keys handler
|
|
831
|
+
if self._media_keys:
|
|
832
|
+
await self._media_keys.stop()
|
|
515
833
|
|
|
516
834
|
# Shutdown player - MUST stop mpv
|
|
517
835
|
logger.info(" Stopping player...")
|
wrkmon/cli.py
CHANGED
|
@@ -285,5 +285,105 @@ def config() -> None:
|
|
|
285
285
|
console.print(f"Cache TTL: {cfg.url_ttl_hours} hours")
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
@app.command()
|
|
289
|
+
def update(
|
|
290
|
+
check_only: bool = typer.Option(
|
|
291
|
+
False, "--check", "-c", help="Only check for updates, don't install"
|
|
292
|
+
),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Check for and install updates."""
|
|
295
|
+
from wrkmon.utils.updater import check_for_updates, perform_update
|
|
296
|
+
|
|
297
|
+
console.print("[dim]Checking for updates...[/dim]")
|
|
298
|
+
|
|
299
|
+
update_info = check_for_updates()
|
|
300
|
+
|
|
301
|
+
if update_info is None:
|
|
302
|
+
console.print("[yellow]Could not check for updates. Please try again later.[/yellow]")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
console.print(f"Current version: [cyan]{update_info.current_version}[/cyan]")
|
|
306
|
+
console.print(f"Latest version: [cyan]{update_info.latest_version}[/cyan]")
|
|
307
|
+
|
|
308
|
+
if not update_info.is_update_available:
|
|
309
|
+
console.print("\n[green]You are running the latest version![/green]")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
console.print(f"\n[yellow]Update available![/yellow]")
|
|
313
|
+
|
|
314
|
+
if check_only:
|
|
315
|
+
console.print(f"\nTo update, run: [bold]{update_info.update_command}[/bold]")
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
# Prompt for update
|
|
319
|
+
if typer.confirm("Do you want to update now?"):
|
|
320
|
+
console.print("[dim]Updating...[/dim]")
|
|
321
|
+
success, message = perform_update()
|
|
322
|
+
if success:
|
|
323
|
+
console.print(f"[green]{message}[/green]")
|
|
324
|
+
else:
|
|
325
|
+
console.print(f"[red]{message}[/red]")
|
|
326
|
+
else:
|
|
327
|
+
console.print(f"\nTo update later, run: [bold]{update_info.update_command}[/bold]")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.command()
|
|
331
|
+
def deps() -> None:
|
|
332
|
+
"""Check and manage dependencies."""
|
|
333
|
+
from wrkmon.utils.updater import check_dependencies, get_deno_install_command
|
|
334
|
+
from wrkmon.utils.mpv_installer import is_mpv_installed, get_mpv_path
|
|
335
|
+
|
|
336
|
+
console.print("[bold]Dependency Status[/bold]\n")
|
|
337
|
+
|
|
338
|
+
deps_status = check_dependencies()
|
|
339
|
+
|
|
340
|
+
table = Table()
|
|
341
|
+
table.add_column("Dependency", style="bold")
|
|
342
|
+
table.add_column("Status")
|
|
343
|
+
table.add_column("Required")
|
|
344
|
+
table.add_column("Description", style="dim")
|
|
345
|
+
|
|
346
|
+
for name, info in deps_status.items():
|
|
347
|
+
if name == "js_runtime":
|
|
348
|
+
continue # Skip aggregate
|
|
349
|
+
|
|
350
|
+
status = "[green]Installed[/green]" if info["installed"] else "[red]Missing[/red]"
|
|
351
|
+
required = "[yellow]Yes[/yellow]" if info["required"] else "No"
|
|
352
|
+
table.add_row(name, status, required, info["description"])
|
|
353
|
+
|
|
354
|
+
console.print(table)
|
|
355
|
+
|
|
356
|
+
# Show recommendations
|
|
357
|
+
if not deps_status["mpv"]["installed"]:
|
|
358
|
+
console.print("\n[red]mpv is required for audio playback![/red]")
|
|
359
|
+
console.print("Install with:")
|
|
360
|
+
console.print(" [bold]Linux:[/bold] sudo apt install mpv")
|
|
361
|
+
console.print(" [bold]macOS:[/bold] brew install mpv")
|
|
362
|
+
console.print(" [bold]Windows:[/bold] winget install mpv")
|
|
363
|
+
|
|
364
|
+
if not deps_status["js_runtime"]["installed"]:
|
|
365
|
+
console.print("\n[yellow]No JavaScript runtime found.[/yellow]")
|
|
366
|
+
console.print("For better YouTube compatibility, install deno:")
|
|
367
|
+
console.print(f" [bold]{get_deno_install_command()}[/bold]")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command()
|
|
371
|
+
def install_deno() -> None:
|
|
372
|
+
"""Install deno for better YouTube compatibility."""
|
|
373
|
+
from wrkmon.utils.updater import install_deno as do_install_deno, is_deno_installed
|
|
374
|
+
|
|
375
|
+
if is_deno_installed():
|
|
376
|
+
console.print("[green]deno is already installed![/green]")
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
console.print("[dim]Attempting to install deno...[/dim]")
|
|
380
|
+
success, message = do_install_deno()
|
|
381
|
+
|
|
382
|
+
if success:
|
|
383
|
+
console.print(f"[green]{message}[/green]")
|
|
384
|
+
else:
|
|
385
|
+
console.print(f"[yellow]{message}[/yellow]")
|
|
386
|
+
|
|
387
|
+
|
|
288
388
|
if __name__ == "__main__":
|
|
289
389
|
app()
|