wrkmon 1.0.1__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 +297 -3
- 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 +168 -12
- wrkmon/ui/widgets/header.py +31 -1
- wrkmon/ui/widgets/player_bar.py +40 -7
- 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.1.dist-info → wrkmon-1.2.0.dist-info}/METADATA +170 -166
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/RECORD +23 -18
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/entry_points.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/top_level.txt +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/WHEEL +0 -0
- {wrkmon-1.0.1.dist-info → wrkmon-1.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|