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,377 @@
1
+ """Cross-platform media key support for wrkmon.
2
+
3
+ Linux: Uses MPRIS D-Bus interface (standard way for desktop environments)
4
+ Windows/macOS: Uses pynput for global hotkey listening
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import sys
10
+ from typing import Callable, Optional, Any
11
+
12
+ logger = logging.getLogger("wrkmon.media_keys")
13
+
14
+ # Platform detection
15
+ IS_LINUX = sys.platform == "linux"
16
+ IS_WINDOWS = sys.platform == "win32"
17
+ IS_MACOS = sys.platform == "darwin"
18
+
19
+ # Check available backends
20
+ MPRIS_AVAILABLE = False
21
+ PYNPUT_AVAILABLE = False
22
+
23
+ if IS_LINUX:
24
+ try:
25
+ from dbus_next.aio import MessageBus
26
+ from dbus_next.service import ServiceInterface, method, dbus_property
27
+ from dbus_next import Variant, BusType
28
+ MPRIS_AVAILABLE = True
29
+ except ImportError:
30
+ logger.debug("dbus-next not installed, MPRIS support disabled")
31
+
32
+ if IS_WINDOWS or IS_MACOS:
33
+ try:
34
+ from pynput import keyboard
35
+ PYNPUT_AVAILABLE = True
36
+ except ImportError:
37
+ logger.debug("pynput not installed, global hotkey support disabled")
38
+
39
+
40
+ # ============================================
41
+ # MPRIS Implementation (Linux)
42
+ # ============================================
43
+
44
+ if MPRIS_AVAILABLE:
45
+ class MPRISRootInterface(ServiceInterface):
46
+ """MPRIS MediaPlayer2 root interface."""
47
+
48
+ def __init__(self, callback: Callable):
49
+ super().__init__("org.mpris.MediaPlayer2")
50
+ self._callback = callback
51
+
52
+ @method()
53
+ def Raise(self):
54
+ pass
55
+
56
+ @method()
57
+ def Quit(self):
58
+ asyncio.create_task(self._callback("quit"))
59
+
60
+ @dbus_property()
61
+ def CanQuit(self) -> "b":
62
+ return True
63
+
64
+ @dbus_property()
65
+ def CanRaise(self) -> "b":
66
+ return False
67
+
68
+ @dbus_property()
69
+ def HasTrackList(self) -> "b":
70
+ return False
71
+
72
+ @dbus_property()
73
+ def Identity(self) -> "s":
74
+ return "wrkmon"
75
+
76
+ @dbus_property()
77
+ def DesktopEntry(self) -> "s":
78
+ return "wrkmon"
79
+
80
+ @dbus_property()
81
+ def SupportedUriSchemes(self) -> "as":
82
+ return ["https", "http"]
83
+
84
+ @dbus_property()
85
+ def SupportedMimeTypes(self) -> "as":
86
+ return ["audio/mpeg", "audio/ogg", "audio/webm"]
87
+
88
+
89
+ class MPRISPlayerInterface(ServiceInterface):
90
+ """MPRIS MediaPlayer2.Player interface."""
91
+
92
+ def __init__(self, callback: Callable):
93
+ super().__init__("org.mpris.MediaPlayer2.Player")
94
+ self._callback = callback
95
+ self._is_playing = False
96
+ self._position = 0
97
+ self._metadata: dict = {}
98
+ self._volume = 1.0
99
+
100
+ @method()
101
+ def Next(self):
102
+ logger.info("MPRIS: Next track")
103
+ asyncio.create_task(self._callback("next"))
104
+
105
+ @method()
106
+ def Previous(self):
107
+ logger.info("MPRIS: Previous track")
108
+ asyncio.create_task(self._callback("previous"))
109
+
110
+ @method()
111
+ def Pause(self):
112
+ logger.info("MPRIS: Pause")
113
+ asyncio.create_task(self._callback("pause"))
114
+
115
+ @method()
116
+ def PlayPause(self):
117
+ logger.info("MPRIS: PlayPause")
118
+ asyncio.create_task(self._callback("play_pause"))
119
+
120
+ @method()
121
+ def Stop(self):
122
+ logger.info("MPRIS: Stop")
123
+ asyncio.create_task(self._callback("stop"))
124
+
125
+ @method()
126
+ def Play(self):
127
+ logger.info("MPRIS: Play")
128
+ asyncio.create_task(self._callback("play"))
129
+
130
+ @method()
131
+ def Seek(self, offset: "x"):
132
+ asyncio.create_task(self._callback("seek", offset / 1_000_000))
133
+
134
+ @method()
135
+ def SetPosition(self, track_id: "o", position: "x"):
136
+ asyncio.create_task(self._callback("set_position", position / 1_000_000))
137
+
138
+ @dbus_property()
139
+ def PlaybackStatus(self) -> "s":
140
+ return "Playing" if self._is_playing else "Paused"
141
+
142
+ @dbus_property()
143
+ def Rate(self) -> "d":
144
+ return 1.0
145
+
146
+ @dbus_property()
147
+ def Metadata(self) -> "a{sv}":
148
+ return self._metadata
149
+
150
+ @dbus_property()
151
+ def Volume(self) -> "d":
152
+ return self._volume
153
+
154
+ @Volume.setter
155
+ def Volume(self, value: "d"):
156
+ self._volume = max(0.0, min(1.0, value))
157
+ asyncio.create_task(self._callback("set_volume", int(self._volume * 100)))
158
+
159
+ @dbus_property()
160
+ def Position(self) -> "x":
161
+ return int(self._position * 1_000_000)
162
+
163
+ @dbus_property()
164
+ def MinimumRate(self) -> "d":
165
+ return 1.0
166
+
167
+ @dbus_property()
168
+ def MaximumRate(self) -> "d":
169
+ return 1.0
170
+
171
+ @dbus_property()
172
+ def CanGoNext(self) -> "b":
173
+ return True
174
+
175
+ @dbus_property()
176
+ def CanGoPrevious(self) -> "b":
177
+ return True
178
+
179
+ @dbus_property()
180
+ def CanPlay(self) -> "b":
181
+ return True
182
+
183
+ @dbus_property()
184
+ def CanPause(self) -> "b":
185
+ return True
186
+
187
+ @dbus_property()
188
+ def CanSeek(self) -> "b":
189
+ return True
190
+
191
+ @dbus_property()
192
+ def CanControl(self) -> "b":
193
+ return True
194
+
195
+ def set_playing(self, playing: bool):
196
+ self._is_playing = playing
197
+
198
+ def set_position(self, pos: float):
199
+ self._position = pos
200
+
201
+ def set_volume(self, vol: int):
202
+ self._volume = vol / 100.0
203
+
204
+ def set_metadata(self, title: str, artist: str, duration: int, art_url: str, track_id: str):
205
+ self._metadata = {
206
+ "mpris:trackid": Variant("o", f"/org/mpris/MediaPlayer2/Track/{track_id or 'unknown'}"),
207
+ "mpris:length": Variant("x", duration * 1_000_000),
208
+ "xesam:title": Variant("s", title),
209
+ "xesam:artist": Variant("as", [artist] if artist else []),
210
+ }
211
+ if art_url:
212
+ self._metadata["mpris:artUrl"] = Variant("s", art_url)
213
+
214
+
215
+ # ============================================
216
+ # Cross-platform MediaKeysHandler
217
+ # ============================================
218
+
219
+ class MediaKeysHandler:
220
+ """Cross-platform media keys handler."""
221
+
222
+ def __init__(self, callback: Callable):
223
+ """
224
+ Initialize media keys handler.
225
+
226
+ Args:
227
+ callback: Async function called with (command, *args).
228
+ Commands: 'play', 'pause', 'play_pause', 'stop',
229
+ 'next', 'previous', 'seek', 'set_volume', 'quit'
230
+ """
231
+ self._callback = callback
232
+ self._running = False
233
+
234
+ # Platform-specific components
235
+ self._bus: Optional[Any] = None
236
+ self._root_interface: Optional[Any] = None
237
+ self._player_interface: Optional[Any] = None
238
+ self._keyboard_listener: Optional[Any] = None
239
+
240
+ @property
241
+ def is_available(self) -> bool:
242
+ """Check if media keys are available on this platform."""
243
+ if IS_LINUX:
244
+ return MPRIS_AVAILABLE
245
+ return PYNPUT_AVAILABLE
246
+
247
+ @property
248
+ def is_running(self) -> bool:
249
+ """Check if media keys handler is active."""
250
+ return self._running
251
+
252
+ @property
253
+ def backend_name(self) -> str:
254
+ """Get the name of the active backend."""
255
+ if IS_LINUX and MPRIS_AVAILABLE:
256
+ return "MPRIS (D-Bus)"
257
+ elif PYNPUT_AVAILABLE:
258
+ return "pynput (global hotkeys)"
259
+ return "none"
260
+
261
+ async def start(self) -> bool:
262
+ """Start listening for media keys."""
263
+ if self._running:
264
+ return True
265
+
266
+ if IS_LINUX:
267
+ return await self._start_mpris()
268
+ elif PYNPUT_AVAILABLE:
269
+ return self._start_pynput()
270
+
271
+ logger.info("No media key backend available")
272
+ return False
273
+
274
+ async def stop(self):
275
+ """Stop listening for media keys."""
276
+ if IS_LINUX and self._bus:
277
+ try:
278
+ self._bus.disconnect()
279
+ except Exception:
280
+ pass
281
+
282
+ if self._keyboard_listener:
283
+ try:
284
+ self._keyboard_listener.stop()
285
+ except Exception:
286
+ pass
287
+
288
+ self._running = False
289
+ logger.info("Media keys handler stopped")
290
+
291
+ async def _start_mpris(self) -> bool:
292
+ """Start MPRIS D-Bus service (Linux)."""
293
+ if not MPRIS_AVAILABLE:
294
+ return False
295
+
296
+ try:
297
+ self._bus = await MessageBus(bus_type=BusType.SESSION).connect()
298
+ self._root_interface = MPRISRootInterface(self._callback)
299
+ self._player_interface = MPRISPlayerInterface(self._callback)
300
+
301
+ self._bus.export("/org/mpris/MediaPlayer2", self._root_interface)
302
+ self._bus.export("/org/mpris/MediaPlayer2", self._player_interface)
303
+
304
+ await self._bus.request_name("org.mpris.MediaPlayer2.wrkmon")
305
+
306
+ self._running = True
307
+ logger.info("MPRIS service started - media keys active")
308
+ return True
309
+
310
+ except Exception as e:
311
+ logger.warning(f"Failed to start MPRIS: {e}")
312
+ return False
313
+
314
+ def _start_pynput(self) -> bool:
315
+ """Start pynput global hotkey listener (Windows/macOS)."""
316
+ if not PYNPUT_AVAILABLE:
317
+ return False
318
+
319
+ try:
320
+ def on_press(key):
321
+ try:
322
+ # Check for media keys
323
+ if hasattr(key, 'name'):
324
+ if key == keyboard.Key.media_play_pause:
325
+ asyncio.create_task(self._callback("play_pause"))
326
+ elif key == keyboard.Key.media_next:
327
+ asyncio.create_task(self._callback("next"))
328
+ elif key == keyboard.Key.media_previous:
329
+ asyncio.create_task(self._callback("previous"))
330
+ elif key == keyboard.Key.media_volume_up:
331
+ asyncio.create_task(self._callback("volume_up"))
332
+ elif key == keyboard.Key.media_volume_down:
333
+ asyncio.create_task(self._callback("volume_down"))
334
+ elif key == keyboard.Key.media_volume_mute:
335
+ asyncio.create_task(self._callback("mute"))
336
+ except Exception as e:
337
+ logger.debug(f"Error handling key: {e}")
338
+
339
+ self._keyboard_listener = keyboard.Listener(on_press=on_press)
340
+ self._keyboard_listener.start()
341
+
342
+ self._running = True
343
+ logger.info("pynput listener started - media keys active")
344
+ return True
345
+
346
+ except Exception as e:
347
+ logger.warning(f"Failed to start pynput listener: {e}")
348
+ return False
349
+
350
+ def update_playback(self, is_playing: bool = None, position: float = None, volume: int = None):
351
+ """Update playback state (for MPRIS)."""
352
+ if not self._player_interface:
353
+ return
354
+ if is_playing is not None:
355
+ self._player_interface.set_playing(is_playing)
356
+ if position is not None:
357
+ self._player_interface.set_position(position)
358
+ if volume is not None:
359
+ self._player_interface.set_volume(volume)
360
+
361
+ def update_track(self, title: str = "", artist: str = "", duration: int = 0,
362
+ art_url: str = "", track_id: str = ""):
363
+ """Update current track metadata (for MPRIS)."""
364
+ if self._player_interface:
365
+ self._player_interface.set_metadata(title, artist, duration, art_url, track_id)
366
+
367
+
368
+ # Singleton instance
369
+ _handler: Optional[MediaKeysHandler] = None
370
+
371
+
372
+ def get_media_keys_handler(callback: Callable = None) -> Optional[MediaKeysHandler]:
373
+ """Get or create the media keys handler."""
374
+ global _handler
375
+ if _handler is None and callback:
376
+ _handler = MediaKeysHandler(callback)
377
+ return _handler
wrkmon/core/queue.py CHANGED
@@ -16,6 +16,7 @@ class QueueItem:
16
16
  channel: str
17
17
  duration: int
18
18
  added_at: float = 0.0
19
+ playback_position: int = 0 # Last played position in seconds
19
20
 
20
21
  @classmethod
21
22
  def from_search_result(cls, result: SearchResult) -> "QueueItem":
@@ -28,8 +29,34 @@ class QueueItem:
28
29
  channel=result.channel,
29
30
  duration=result.duration,
30
31
  added_at=time.time(),
32
+ playback_position=0,
31
33
  )
32
34
 
35
+ @classmethod
36
+ def from_dict(cls, data: dict) -> "QueueItem":
37
+ """Create a queue item from a dictionary."""
38
+ import time
39
+
40
+ return cls(
41
+ video_id=data["video_id"],
42
+ title=data["title"],
43
+ channel=data["channel"],
44
+ duration=data["duration"],
45
+ added_at=data.get("added_at", time.time()),
46
+ playback_position=data.get("playback_position", 0),
47
+ )
48
+
49
+ def to_dict(self) -> dict:
50
+ """Convert to dictionary for serialization."""
51
+ return {
52
+ "video_id": self.video_id,
53
+ "title": self.title,
54
+ "channel": self.channel,
55
+ "duration": self.duration,
56
+ "added_at": self.added_at,
57
+ "playback_position": self.playback_position,
58
+ }
59
+
33
60
  @property
34
61
  def url(self) -> str:
35
62
  """Get the YouTube URL."""
@@ -262,3 +289,31 @@ class PlayQueue:
262
289
  if self.shuffle_mode and self._shuffle_order:
263
290
  return [self.items[i] for i in self._shuffle_order]
264
291
  return list(self.items)
292
+
293
+ def update_playback_position(self, video_id: str, position: int) -> None:
294
+ """Update the playback position for a queue item."""
295
+ for item in self.items:
296
+ if item.video_id == video_id:
297
+ item.playback_position = position
298
+ break
299
+
300
+ def get_playback_position(self, video_id: str) -> int:
301
+ """Get the saved playback position for a queue item."""
302
+ for item in self.items:
303
+ if item.video_id == video_id:
304
+ return item.playback_position
305
+ return 0
306
+
307
+ def to_dict_list(self) -> list[dict]:
308
+ """Convert all items to list of dicts for serialization."""
309
+ return [item.to_dict() for item in self.items]
310
+
311
+ def load_from_dicts(self, items: list[dict], current_index: int = -1) -> None:
312
+ """Load queue from list of dicts."""
313
+ self.clear()
314
+ for data in items:
315
+ item = QueueItem.from_dict(data)
316
+ self.items.append(item)
317
+ self.current_index = current_index if -1 <= current_index < len(self.items) else -1
318
+ if self.shuffle_mode:
319
+ self._create_shuffle_order()
wrkmon/core/youtube.py CHANGED
@@ -176,3 +176,105 @@ class YouTubeClient:
176
176
  )
177
177
  except Exception:
178
178
  return None
179
+
180
+ async def get_trending_music(self, max_results: int = 10) -> list[SearchResult]:
181
+ """Get trending/popular music videos."""
182
+ return await asyncio.to_thread(self._get_trending_sync, max_results)
183
+
184
+ def _get_trending_sync(self, max_results: int) -> list[SearchResult]:
185
+ """Fetch trending music from YouTube Music charts or popular searches."""
186
+ results = []
187
+
188
+ # Try to get from YouTube Music trending/charts
189
+ trending_queries = [
190
+ "https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf", # Popular Music
191
+ "https://www.youtube.com/playlist?list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i", # Trending Music
192
+ ]
193
+
194
+ opts = {
195
+ **self._search_opts,
196
+ "playlistend": max_results,
197
+ "extract_flat": True,
198
+ }
199
+
200
+ # Try playlist first
201
+ for playlist_url in trending_queries:
202
+ try:
203
+ with yt_dlp.YoutubeDL(opts) as ydl:
204
+ info = ydl.extract_info(playlist_url, download=False)
205
+
206
+ if info and "entries" in info:
207
+ for entry in info["entries"][:max_results]:
208
+ if entry is None:
209
+ continue
210
+ result = SearchResult(
211
+ video_id=entry.get("id", ""),
212
+ title=entry.get("title", "Unknown"),
213
+ channel=entry.get("channel", entry.get("uploader", "Unknown")),
214
+ duration=entry.get("duration", 0) or 0,
215
+ view_count=entry.get("view_count", 0) or 0,
216
+ thumbnail_url=entry.get("thumbnail"),
217
+ )
218
+ if result.video_id:
219
+ results.append(result)
220
+
221
+ if results:
222
+ return results[:max_results]
223
+ except Exception:
224
+ continue
225
+
226
+ # Fallback: search for popular music
227
+ fallback_searches = [
228
+ "popular music 2024",
229
+ "trending songs",
230
+ "top hits music",
231
+ ]
232
+
233
+ for query in fallback_searches:
234
+ try:
235
+ search_results = self._search_sync(query, max_results)
236
+ if search_results:
237
+ return search_results
238
+ except Exception:
239
+ continue
240
+
241
+ return results
242
+
243
+ async def get_recommendations(self, video_id: str, max_results: int = 5) -> list[SearchResult]:
244
+ """Get recommended videos based on a video (related videos)."""
245
+ return await asyncio.to_thread(self._get_recommendations_sync, video_id, max_results)
246
+
247
+ def _get_recommendations_sync(self, video_id: str, max_results: int) -> list[SearchResult]:
248
+ """Get related/recommended videos."""
249
+ results = []
250
+ url = f"https://www.youtube.com/watch?v={video_id}"
251
+
252
+ opts = {
253
+ "quiet": True,
254
+ "no_warnings": True,
255
+ "extract_flat": False,
256
+ "noplaylist": True,
257
+ }
258
+
259
+ try:
260
+ with yt_dlp.YoutubeDL(opts) as ydl:
261
+ info = ydl.extract_info(url, download=False)
262
+
263
+ if info and "related_videos" in info:
264
+ for entry in info["related_videos"][:max_results]:
265
+ if entry is None:
266
+ continue
267
+ result = SearchResult(
268
+ video_id=entry.get("id", ""),
269
+ title=entry.get("title", "Unknown"),
270
+ channel=entry.get("channel", entry.get("uploader", "Unknown")),
271
+ duration=entry.get("duration", 0) or 0,
272
+ view_count=entry.get("view_count", 0) or 0,
273
+ thumbnail_url=entry.get("thumbnail"),
274
+ )
275
+ if result.video_id:
276
+ results.append(result)
277
+ except Exception:
278
+ pass
279
+
280
+ return results
wrkmon/data/database.py CHANGED
@@ -424,3 +424,123 @@ class Database:
424
424
  )
425
425
 
426
426
  return entries
427
+
428
+ # ==================== Queue Persistence ====================
429
+
430
+ def save_queue(
431
+ self,
432
+ items: list[dict],
433
+ current_index: int,
434
+ shuffle_mode: bool,
435
+ repeat_mode: str,
436
+ ) -> None:
437
+ """
438
+ Save the current queue to database.
439
+
440
+ Args:
441
+ items: List of dicts with video_id, title, channel, duration, playback_position
442
+ current_index: Current playing index
443
+ shuffle_mode: Whether shuffle is enabled
444
+ repeat_mode: 'none', 'one', or 'all'
445
+ """
446
+ # Clear existing queue
447
+ self._conn.execute("DELETE FROM queue")
448
+
449
+ # Save each item
450
+ for position, item in enumerate(items):
451
+ # Get or create track
452
+ track = self.get_or_create_track(
453
+ video_id=item["video_id"],
454
+ title=item["title"],
455
+ channel=item["channel"],
456
+ duration=item["duration"],
457
+ )
458
+
459
+ self._conn.execute(
460
+ """
461
+ INSERT INTO queue (track_id, position, playback_position)
462
+ VALUES (?, ?, ?)
463
+ """,
464
+ (track.id, position, item.get("playback_position", 0)),
465
+ )
466
+
467
+ # Save queue state
468
+ self._conn.execute(
469
+ """
470
+ UPDATE queue_state SET
471
+ current_index = ?,
472
+ shuffle_mode = ?,
473
+ repeat_mode = ?,
474
+ updated_at = ?
475
+ WHERE id = 1
476
+ """,
477
+ (current_index, int(shuffle_mode), repeat_mode, datetime.now()),
478
+ )
479
+
480
+ self._conn.commit()
481
+
482
+ def load_queue(self) -> tuple[list[dict], int, bool, str]:
483
+ """
484
+ Load the saved queue from database.
485
+
486
+ Returns:
487
+ Tuple of (items, current_index, shuffle_mode, repeat_mode)
488
+ items is a list of dicts with video_id, title, channel, duration, playback_position
489
+ """
490
+ items = []
491
+
492
+ # Load queue items
493
+ cursor = self._conn.execute(
494
+ """
495
+ SELECT q.position, q.playback_position, t.video_id, t.title, t.channel, t.duration
496
+ FROM queue q
497
+ JOIN tracks t ON t.id = q.track_id
498
+ ORDER BY q.position
499
+ """
500
+ )
501
+
502
+ for row in cursor.fetchall():
503
+ items.append({
504
+ "video_id": row["video_id"],
505
+ "title": row["title"],
506
+ "channel": row["channel"],
507
+ "duration": row["duration"],
508
+ "playback_position": row["playback_position"],
509
+ })
510
+
511
+ # Load queue state
512
+ cursor = self._conn.execute(
513
+ "SELECT current_index, shuffle_mode, repeat_mode FROM queue_state WHERE id = 1"
514
+ )
515
+ row = cursor.fetchone()
516
+
517
+ if row:
518
+ current_index = row["current_index"]
519
+ shuffle_mode = bool(row["shuffle_mode"])
520
+ repeat_mode = row["repeat_mode"]
521
+ else:
522
+ current_index = -1
523
+ shuffle_mode = False
524
+ repeat_mode = "none"
525
+
526
+ return items, current_index, shuffle_mode, repeat_mode
527
+
528
+ def update_queue_item_position(self, video_id: str, playback_position: int) -> None:
529
+ """Update the playback position for a queue item."""
530
+ self._conn.execute(
531
+ """
532
+ UPDATE queue SET playback_position = ?
533
+ WHERE track_id = (SELECT id FROM tracks WHERE video_id = ?)
534
+ """,
535
+ (playback_position, video_id),
536
+ )
537
+ self._conn.commit()
538
+
539
+ def clear_queue(self) -> None:
540
+ """Clear the saved queue."""
541
+ self._conn.execute("DELETE FROM queue")
542
+ self._conn.execute(
543
+ "UPDATE queue_state SET current_index = -1, updated_at = ? WHERE id = 1",
544
+ (datetime.now(),),
545
+ )
546
+ self._conn.commit()