wrkmon 1.0.1__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/core/player.py ADDED
@@ -0,0 +1,301 @@
1
+ """Audio player using mpv with IPC for proper control."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from typing import Optional
11
+
12
+ from wrkmon.utils.config import get_config
13
+
14
+ logger = logging.getLogger("wrkmon.player")
15
+
16
+
17
+ class AudioPlayer:
18
+ """mpv audio player with IPC for pause/resume/seek."""
19
+
20
+ def __init__(self):
21
+ self._config = get_config()
22
+ self._process: Optional[subprocess.Popen] = None
23
+ self._current_url: Optional[str] = None
24
+ self._volume = 80
25
+ self._paused = False
26
+ self._position = 0.0
27
+ self._duration = 0.0
28
+ self._pipe_path = r"\\.\pipe\wrkmon_mpv" if sys.platform == "win32" else "/tmp/wrkmon_mpv.sock"
29
+ self._pipe = None
30
+
31
+ @property
32
+ def is_connected(self) -> bool:
33
+ return self._process is not None and self._process.poll() is None
34
+
35
+ @property
36
+ def is_playing(self) -> bool:
37
+ return self.is_connected and not self._paused
38
+
39
+ @property
40
+ def current_position(self) -> float:
41
+ return self._position
42
+
43
+ @property
44
+ def duration(self) -> float:
45
+ return self._duration
46
+
47
+ @property
48
+ def volume(self) -> int:
49
+ return self._volume
50
+
51
+ async def start(self) -> bool:
52
+ """Initialize player (no-op, starts on play)."""
53
+ return True
54
+
55
+ def _send_command(self, command: list) -> Optional[dict]:
56
+ """Send command to mpv via IPC and get response."""
57
+ if not self.is_connected:
58
+ return None
59
+
60
+ try:
61
+ if sys.platform == "win32":
62
+ import win32file
63
+ import win32pipe
64
+
65
+ # Open pipe
66
+ handle = win32file.CreateFile(
67
+ self._pipe_path,
68
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
69
+ 0, None,
70
+ win32file.OPEN_EXISTING,
71
+ 0, None
72
+ )
73
+
74
+ # Send command
75
+ cmd = json.dumps({"command": command}) + "\n"
76
+ win32file.WriteFile(handle, cmd.encode())
77
+
78
+ # Read response
79
+ result, data = win32file.ReadFile(handle, 4096)
80
+ win32file.CloseHandle(handle)
81
+
82
+ if data:
83
+ return json.loads(data.decode().strip())
84
+ else:
85
+ # Unix socket
86
+ import socket
87
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
88
+ sock.connect(self._pipe_path)
89
+ sock.settimeout(1.0)
90
+
91
+ cmd = json.dumps({"command": command}) + "\n"
92
+ sock.send(cmd.encode())
93
+
94
+ data = sock.recv(4096)
95
+ sock.close()
96
+
97
+ if data:
98
+ return json.loads(data.decode().strip())
99
+
100
+ except Exception as e:
101
+ logger.debug(f"IPC command failed: {e}")
102
+
103
+ return None
104
+
105
+ async def _send_command_async(self, command: list) -> Optional[dict]:
106
+ """Async wrapper for send_command."""
107
+ return await asyncio.to_thread(self._send_command, command)
108
+
109
+ async def play(self, url: str) -> bool:
110
+ """Play audio from URL - stealth mode with IPC."""
111
+ logger.info(f"=== player.play() called ===")
112
+ logger.info(f" URL: {url[:100]}...")
113
+
114
+ # Kill any existing playback
115
+ await self.stop()
116
+
117
+ mpv_path = self._config.mpv_path
118
+ logger.info(f" mpv_path: {mpv_path}")
119
+
120
+ # Check if mpv exists
121
+ mpv_exists = os.path.exists(mpv_path) if mpv_path != "mpv" else True
122
+ logger.info(f" mpv exists: {mpv_exists}")
123
+
124
+ # Args with IPC server for control
125
+ args = [
126
+ mpv_path,
127
+ "--no-video",
128
+ "--no-terminal",
129
+ "--really-quiet",
130
+ "--no-osc",
131
+ "--no-osd-bar",
132
+ "--force-window=no",
133
+ "--audio-display=no",
134
+ f"--volume={self._volume}",
135
+ f"--input-ipc-server={self._pipe_path}",
136
+ url,
137
+ ]
138
+ logger.info(f" IPC pipe: {self._pipe_path}")
139
+
140
+ try:
141
+ if sys.platform == "win32":
142
+ logger.info(" Creating Windows subprocess...")
143
+ self._process = subprocess.Popen(
144
+ args,
145
+ stdin=subprocess.DEVNULL,
146
+ stdout=subprocess.DEVNULL,
147
+ stderr=subprocess.DEVNULL,
148
+ creationflags=subprocess.CREATE_NO_WINDOW,
149
+ )
150
+ else:
151
+ logger.info(" Creating Unix subprocess...")
152
+ self._process = subprocess.Popen(
153
+ args,
154
+ stdin=subprocess.DEVNULL,
155
+ stdout=subprocess.DEVNULL,
156
+ stderr=subprocess.DEVNULL,
157
+ start_new_session=True,
158
+ )
159
+
160
+ logger.info(f" Process created, PID: {self._process.pid}")
161
+ self._current_url = url
162
+ self._paused = False
163
+
164
+ # Wait for mpv to start and create pipe
165
+ await asyncio.sleep(1.0)
166
+
167
+ poll_result = self._process.poll()
168
+ if poll_result is None:
169
+ logger.info(" SUCCESS - mpv is running!")
170
+ # Try to get duration
171
+ await self._update_properties()
172
+ return True
173
+ else:
174
+ logger.error(f" FAILED - mpv exited with code: {poll_result}")
175
+ return False
176
+
177
+ except Exception as e:
178
+ logger.exception(f" EXCEPTION: {e}")
179
+ return False
180
+
181
+ async def _update_properties(self) -> None:
182
+ """Update position and duration from mpv."""
183
+ try:
184
+ # Get time position
185
+ result = await self._send_command_async(["get_property", "time-pos"])
186
+ if result and "data" in result:
187
+ self._position = float(result["data"] or 0)
188
+
189
+ # Get duration
190
+ result = await self._send_command_async(["get_property", "duration"])
191
+ if result and "data" in result:
192
+ self._duration = float(result["data"] or 0)
193
+
194
+ # Get pause state
195
+ result = await self._send_command_async(["get_property", "pause"])
196
+ if result and "data" in result:
197
+ self._paused = bool(result["data"])
198
+
199
+ except Exception as e:
200
+ logger.debug(f"Failed to update properties: {e}")
201
+
202
+ async def stop(self) -> None:
203
+ """Stop playback."""
204
+ if self._process:
205
+ try:
206
+ # Try graceful quit first
207
+ self._send_command(["quit"])
208
+ await asyncio.sleep(0.2)
209
+ except Exception:
210
+ pass
211
+
212
+ try:
213
+ self._process.terminate()
214
+ self._process.wait(timeout=2)
215
+ except Exception:
216
+ try:
217
+ self._process.kill()
218
+ except Exception:
219
+ pass
220
+ self._process = None
221
+
222
+ self._paused = False
223
+ self._position = 0.0
224
+ self._duration = 0.0
225
+
226
+ async def pause(self) -> None:
227
+ """Pause playback via IPC."""
228
+ if self.is_connected:
229
+ result = await self._send_command_async(["set_property", "pause", True])
230
+ if result:
231
+ self._paused = True
232
+ logger.info("Paused via IPC")
233
+ else:
234
+ # Fallback - just set flag
235
+ self._paused = True
236
+
237
+ async def resume(self) -> None:
238
+ """Resume playback via IPC."""
239
+ if self.is_connected:
240
+ result = await self._send_command_async(["set_property", "pause", False])
241
+ if result:
242
+ self._paused = False
243
+ logger.info("Resumed via IPC")
244
+ else:
245
+ # Fallback - restart if IPC fails
246
+ if self._current_url:
247
+ await self.play(self._current_url)
248
+ self._paused = False
249
+
250
+ async def toggle_pause(self) -> None:
251
+ """Toggle pause state."""
252
+ if self._paused:
253
+ await self.resume()
254
+ else:
255
+ await self.pause()
256
+
257
+ async def set_volume(self, volume: int) -> None:
258
+ """Set volume."""
259
+ self._volume = max(0, min(100, volume))
260
+ if self.is_connected:
261
+ await self._send_command_async(["set_property", "volume", self._volume])
262
+
263
+ async def seek(self, seconds: float, relative: bool = True) -> None:
264
+ """Seek in current track."""
265
+ if self.is_connected:
266
+ if relative:
267
+ await self._send_command_async(["seek", seconds, "relative"])
268
+ else:
269
+ await self._send_command_async(["seek", seconds, "absolute"])
270
+
271
+ async def get_position(self) -> float:
272
+ """Get current playback position."""
273
+ await self._update_properties()
274
+ return self._position
275
+
276
+ async def get_duration(self) -> float:
277
+ """Get current track duration."""
278
+ await self._update_properties()
279
+ return self._duration
280
+
281
+ async def shutdown(self) -> None:
282
+ """Shutdown player."""
283
+ await self.stop()
284
+
285
+ def on_property_change(self, name: str, callback) -> None:
286
+ """Register callback - not implemented yet."""
287
+ pass
288
+
289
+ async def get_property(self, name: str):
290
+ """Get property."""
291
+ if name == "volume":
292
+ return self._volume
293
+ if name == "pause":
294
+ return self._paused
295
+ if name == "time-pos":
296
+ await self._update_properties()
297
+ return self._position
298
+ if name == "duration":
299
+ await self._update_properties()
300
+ return self._duration
301
+ return None
wrkmon/core/queue.py ADDED
@@ -0,0 +1,264 @@
1
+ """Play queue management."""
2
+
3
+ import random
4
+ from dataclasses import dataclass, field
5
+ from typing import Optional
6
+
7
+ from wrkmon.core.youtube import SearchResult
8
+
9
+
10
+ @dataclass
11
+ class QueueItem:
12
+ """An item in the play queue."""
13
+
14
+ video_id: str
15
+ title: str
16
+ channel: str
17
+ duration: int
18
+ added_at: float = 0.0
19
+
20
+ @classmethod
21
+ def from_search_result(cls, result: SearchResult) -> "QueueItem":
22
+ """Create a queue item from a search result."""
23
+ import time
24
+
25
+ return cls(
26
+ video_id=result.video_id,
27
+ title=result.title,
28
+ channel=result.channel,
29
+ duration=result.duration,
30
+ added_at=time.time(),
31
+ )
32
+
33
+ @property
34
+ def url(self) -> str:
35
+ """Get the YouTube URL."""
36
+ return f"https://www.youtube.com/watch?v={self.video_id}"
37
+
38
+
39
+ @dataclass
40
+ class PlayQueue:
41
+ """Manages the play queue."""
42
+
43
+ items: list[QueueItem] = field(default_factory=list)
44
+ current_index: int = -1
45
+ shuffle_mode: bool = False
46
+ repeat_mode: str = "none" # none, one, all
47
+ _shuffle_order: list[int] = field(default_factory=list)
48
+
49
+ @property
50
+ def current(self) -> Optional[QueueItem]:
51
+ """Get the current item."""
52
+ if 0 <= self.current_index < len(self.items):
53
+ if self.shuffle_mode and self._shuffle_order:
54
+ actual_index = self._shuffle_order[self.current_index]
55
+ return self.items[actual_index]
56
+ return self.items[self.current_index]
57
+ return None
58
+
59
+ @property
60
+ def is_empty(self) -> bool:
61
+ """Check if queue is empty."""
62
+ return len(self.items) == 0
63
+
64
+ @property
65
+ def length(self) -> int:
66
+ """Get queue length."""
67
+ return len(self.items)
68
+
69
+ @property
70
+ def has_next(self) -> bool:
71
+ """Check if there's a next item."""
72
+ if self.repeat_mode == "all":
73
+ return len(self.items) > 0
74
+ if self.repeat_mode == "one":
75
+ return self.current is not None
76
+ return self.current_index < len(self.items) - 1
77
+
78
+ @property
79
+ def has_previous(self) -> bool:
80
+ """Check if there's a previous item."""
81
+ if self.repeat_mode in ("all", "one"):
82
+ return len(self.items) > 0
83
+ return self.current_index > 0
84
+
85
+ def add(self, item: QueueItem) -> int:
86
+ """Add an item to the queue. Returns position."""
87
+ self.items.append(item)
88
+ if self.shuffle_mode:
89
+ self._shuffle_order.append(len(self.items) - 1)
90
+ return len(self.items) - 1
91
+
92
+ def add_search_result(self, result: SearchResult) -> int:
93
+ """Add a search result to the queue."""
94
+ return self.add(QueueItem.from_search_result(result))
95
+
96
+ def add_multiple(self, items: list[QueueItem]) -> None:
97
+ """Add multiple items to the queue."""
98
+ for item in items:
99
+ self.add(item)
100
+
101
+ def remove(self, index: int) -> Optional[QueueItem]:
102
+ """Remove an item by index."""
103
+ if 0 <= index < len(self.items):
104
+ item = self.items.pop(index)
105
+
106
+ # Update shuffle order
107
+ if self.shuffle_mode:
108
+ self._shuffle_order = [i for i in self._shuffle_order if i != index]
109
+ self._shuffle_order = [i - 1 if i > index else i for i in self._shuffle_order]
110
+
111
+ # Update current index
112
+ if index < self.current_index:
113
+ self.current_index -= 1
114
+ elif index == self.current_index:
115
+ # Current item was removed
116
+ if self.current_index >= len(self.items):
117
+ self.current_index = len(self.items) - 1
118
+
119
+ return item
120
+ return None
121
+
122
+ def clear(self) -> None:
123
+ """Clear the queue."""
124
+ self.items.clear()
125
+ self._shuffle_order.clear()
126
+ self.current_index = -1
127
+
128
+ def next(self) -> Optional[QueueItem]:
129
+ """Move to and return the next item."""
130
+ if not self.items:
131
+ return None
132
+
133
+ if self.repeat_mode == "one":
134
+ return self.current
135
+
136
+ if self.shuffle_mode:
137
+ if self.current_index < len(self._shuffle_order) - 1:
138
+ self.current_index += 1
139
+ elif self.repeat_mode == "all":
140
+ self.current_index = 0
141
+ else:
142
+ return None
143
+ else:
144
+ if self.current_index < len(self.items) - 1:
145
+ self.current_index += 1
146
+ elif self.repeat_mode == "all":
147
+ self.current_index = 0
148
+ else:
149
+ return None
150
+
151
+ return self.current
152
+
153
+ def previous(self) -> Optional[QueueItem]:
154
+ """Move to and return the previous item."""
155
+ if not self.items:
156
+ return None
157
+
158
+ if self.repeat_mode == "one":
159
+ return self.current
160
+
161
+ if self.current_index > 0:
162
+ self.current_index -= 1
163
+ elif self.repeat_mode == "all":
164
+ self.current_index = len(self.items) - 1
165
+ else:
166
+ return None
167
+
168
+ return self.current
169
+
170
+ def jump_to(self, index: int) -> Optional[QueueItem]:
171
+ """Jump to a specific index."""
172
+ if 0 <= index < len(self.items):
173
+ self.current_index = index
174
+ return self.current
175
+ return None
176
+
177
+ def shuffle(self) -> None:
178
+ """Enable shuffle mode and create shuffle order."""
179
+ self.shuffle_mode = True
180
+ self._create_shuffle_order()
181
+
182
+ def unshuffle(self) -> None:
183
+ """Disable shuffle mode."""
184
+ self.shuffle_mode = False
185
+ # Update current index to actual position
186
+ if self._shuffle_order and 0 <= self.current_index < len(self._shuffle_order):
187
+ self.current_index = self._shuffle_order[self.current_index]
188
+ self._shuffle_order.clear()
189
+
190
+ def toggle_shuffle(self) -> bool:
191
+ """Toggle shuffle mode. Returns new state."""
192
+ if self.shuffle_mode:
193
+ self.unshuffle()
194
+ else:
195
+ self.shuffle()
196
+ return self.shuffle_mode
197
+
198
+ def _create_shuffle_order(self) -> None:
199
+ """Create a new shuffle order."""
200
+ self._shuffle_order = list(range(len(self.items)))
201
+ random.shuffle(self._shuffle_order)
202
+
203
+ # Move current item to front if playing
204
+ if self.current_index >= 0:
205
+ current_actual = self.current_index
206
+ if current_actual in self._shuffle_order:
207
+ self._shuffle_order.remove(current_actual)
208
+ self._shuffle_order.insert(0, current_actual)
209
+ self.current_index = 0
210
+
211
+ def set_repeat(self, mode: str) -> None:
212
+ """Set repeat mode: none, one, all."""
213
+ if mode in ("none", "one", "all"):
214
+ self.repeat_mode = mode
215
+
216
+ def cycle_repeat(self) -> str:
217
+ """Cycle through repeat modes. Returns new mode."""
218
+ modes = ["none", "one", "all"]
219
+ current_idx = modes.index(self.repeat_mode)
220
+ self.repeat_mode = modes[(current_idx + 1) % len(modes)]
221
+ return self.repeat_mode
222
+
223
+ def move(self, from_index: int, to_index: int) -> bool:
224
+ """Move an item from one position to another."""
225
+ if not (0 <= from_index < len(self.items) and 0 <= to_index < len(self.items)):
226
+ return False
227
+
228
+ item = self.items.pop(from_index)
229
+ self.items.insert(to_index, item)
230
+
231
+ # Update current index
232
+ if self.current_index == from_index:
233
+ self.current_index = to_index
234
+ elif from_index < self.current_index <= to_index:
235
+ self.current_index -= 1
236
+ elif to_index <= self.current_index < from_index:
237
+ self.current_index += 1
238
+
239
+ # Recreate shuffle order if needed
240
+ if self.shuffle_mode:
241
+ self._create_shuffle_order()
242
+
243
+ return True
244
+
245
+ def get_upcoming(self, count: int = 5) -> list[QueueItem]:
246
+ """Get upcoming items in queue."""
247
+ result = []
248
+ start = self.current_index + 1
249
+
250
+ if self.shuffle_mode and self._shuffle_order:
251
+ for i in range(start, min(start + count, len(self._shuffle_order))):
252
+ actual_idx = self._shuffle_order[i]
253
+ result.append(self.items[actual_idx])
254
+ else:
255
+ for i in range(start, min(start + count, len(self.items))):
256
+ result.append(self.items[i])
257
+
258
+ return result
259
+
260
+ def to_list(self) -> list[QueueItem]:
261
+ """Get all items in current play order."""
262
+ if self.shuffle_mode and self._shuffle_order:
263
+ return [self.items[i] for i in self._shuffle_order]
264
+ return list(self.items)