from typing import Any, Dict, Sequence, List, Optional import sys import json import platform import socket import re import subprocess from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args from helper.logger import log from result_table import ResultTable from .get_file import _get_fixed_ipc_pipe import pipeline as ctx def _send_ipc_command(command: Dict[str, Any]) -> Optional[Any]: """Send a command to the MPV IPC pipe and return the response.""" ipc_pipe = _get_fixed_ipc_pipe() request = json.dumps(command) + "\n" try: if platform.system() == 'Windows': # Windows named pipe # Opening in r+b mode to read response try: with open(ipc_pipe, 'r+b', buffering=0) as pipe: pipe.write(request.encode('utf-8')) pipe.flush() # Read response # We'll try to read a line. This might block if MPV is unresponsive. response_line = pipe.readline() if response_line: return json.loads(response_line.decode('utf-8')) except FileNotFoundError: return None # MPV not running except Exception as e: log(f"Windows IPC Error: {e}", file=sys.stderr) return None else: # Unix socket af_unix = getattr(socket, 'AF_UNIX', None) if af_unix is None: log("Unix sockets not supported on this platform", file=sys.stderr) return None try: sock = socket.socket(af_unix, socket.SOCK_STREAM) sock.settimeout(2.0) sock.connect(ipc_pipe) sock.sendall(request.encode('utf-8')) # Read response response_data = b"" while True: try: chunk = sock.recv(4096) if not chunk: break response_data += chunk if b"\n" in chunk: break except socket.timeout: break sock.close() if response_data: # Parse lines, look for response to our request lines = response_data.decode('utf-8').strip().split('\n') for line in lines: try: resp = json.loads(line) # If it has 'error' field, it's a response if 'error' in resp: return resp except: pass except (FileNotFoundError, ConnectionRefusedError): return None # MPV not running except Exception as e: log(f"Unix IPC Error: {e}", file=sys.stderr) return None except Exception as e: log(f"IPC Error: {e}", file=sys.stderr) return None return None def _get_playlist() -> List[Dict[str, Any]]: """Get the current playlist from MPV.""" cmd = {"command": ["get_property", "playlist"], "request_id": 100} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": return resp.get("data", []) return [] def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Manage and play items in the MPV playlist via IPC.""" parsed = parse_cmdlet_args(args, CMDLET) # Handle positional index argument if provided index_arg = parsed.get("index") clear_mode = parsed.get("clear") list_mode = parsed.get("list") play_mode = parsed.get("play") pause_mode = parsed.get("pause") # Handle Play/Pause commands if play_mode: cmd = {"command": ["set_property", "pause", False], "request_id": 103} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": log("Resumed playback") return 0 else: log("Failed to resume playback (MPV not running?)", file=sys.stderr) return 1 if pause_mode: cmd = {"command": ["set_property", "pause", True], "request_id": 104} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": log("Paused playback") return 0 else: log("Failed to pause playback (MPV not running?)", file=sys.stderr) return 1 # Handle piped input (add to playlist) if result: # If result is a list of items, add them to playlist items_to_add = [] if isinstance(result, list): items_to_add = result elif isinstance(result, dict): items_to_add = [result] added_count = 0 for i, item in enumerate(items_to_add): # Extract URL/Path target = None title = None if isinstance(item, dict): target = item.get("target") or item.get("url") or item.get("path") title = item.get("title") or item.get("name") elif hasattr(item, "target"): target = item.target title = getattr(item, "title", None) elif isinstance(item, str): target = item if target: # Add to MPV playlist # We use loadfile with append flag # Use memory:// M3U hack to pass title to MPV # This avoids "invalid parameter" errors with loadfile options # and ensures the title is displayed in the playlist/window if title: # Sanitize title for M3U (remove newlines) safe_title = title.replace('\n', ' ').replace('\r', '') m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" target_to_send = f"memory://{m3u_content}" else: target_to_send = target cmd = {"command": ["loadfile", target_to_send, "append"], "request_id": 200} resp = _send_ipc_command(cmd) if resp is None: # MPV not running (or died) # Start MPV with remaining items _start_mpv(items_to_add[i:]) return 0 elif resp.get("error") == "success": added_count += 1 if title: log(f"Queued: {title}") else: log(f"Queued: {target}") else: error_msg = str(resp.get('error')) log(f"Failed to queue item: {error_msg}", file=sys.stderr) # If error indicates parameter issues, try without options # (Though memory:// should avoid this, we keep fallback just in case) if "option" in error_msg or "parameter" in error_msg: cmd = {"command": ["loadfile", target, "append"], "request_id": 201} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": added_count += 1 log(f"Queued (fallback): {title or target}") if added_count > 0: # If we added items, we might want to play the first one if nothing is playing? # For now, just list the playlist pass # Get playlist from MPV items = _get_playlist() if not items: log("MPV playlist is empty or MPV is not running.") return 0 # If index is provided, perform action (Play or Clear) if index_arg is not None: try: # Handle 1-based index idx = int(index_arg) - 1 if idx < 0 or idx >= len(items): log(f"Index {index_arg} out of range (1-{len(items)}).") return 1 item = items[idx] title = item.get("title") or item.get("filename") or "Unknown" if clear_mode: # Remove item cmd = {"command": ["playlist-remove", idx], "request_id": 101} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": log(f"Removed: {title}") # Refresh items for listing items = _get_playlist() list_mode = True index_arg = None else: log(f"Failed to remove item: {resp.get('error') if resp else 'No response'}") return 1 else: # Play item cmd = {"command": ["playlist-play-index", idx], "request_id": 102} resp = _send_ipc_command(cmd) if resp and resp.get("error") == "success": # Ensure playback starts (unpause) unpause_cmd = {"command": ["set_property", "pause", False], "request_id": 103} _send_ipc_command(unpause_cmd) log(f"Playing: {title}") return 0 else: log(f"Failed to play item: {resp.get('error') if resp else 'No response'}") return 1 except ValueError: log(f"Invalid index: {index_arg}") return 1 # List items (Default action or after clear) if list_mode or index_arg is None: if not items: log("MPV playlist is empty.") return 0 table = ResultTable("MPV Playlist") for i, item in enumerate(items): is_current = item.get("current", False) title = item.get("title") or "" filename = item.get("filename") or "" # Special handling for memory:// M3U playlists (used to pass titles via IPC) if "memory://" in filename and "#EXTINF:" in filename: try: # Extract title from #EXTINF:-1,Title # Use regex to find title between #EXTINF:-1, and newline match = re.search(r"#EXTINF:-1,(.*?)(?:\n|\r|$)", filename) if match: extracted_title = match.group(1).strip() if not title or title == "memory://": title = extracted_title # Extract actual URL # Find the first line that looks like a URL and not a directive lines = filename.splitlines() for line in lines: line = line.strip() if line and not line.startswith('#') and not line.startswith('memory://'): filename = line break except Exception: pass # Truncate if too long if len(title) > 57: title = title[:57] + "..." if len(filename) > 27: filename = filename[:27] + "..." row = table.add_row() row.add_column("#", str(i + 1)) row.add_column("Current", "*" if is_current else "") row.add_column("Title", title) row.add_column("Filename", filename) table.set_row_selection_args(i, [str(i + 1)]) table.set_source_command(".pipe") # Register results with pipeline context so @N selection works ctx.set_last_result_table_overlay(table, items) ctx.set_current_stage_table(table) print(table) return 0 def _start_mpv(items: List[Any]) -> None: """Start MPV with a list of items.""" ipc_pipe = _get_fixed_ipc_pipe() cmd = ['mpv', f'--input-ipc-server={ipc_pipe}'] cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]') # Add items for item in items: target = None title = None if isinstance(item, dict): target = item.get("target") or item.get("url") or item.get("path") title = item.get("title") or item.get("name") elif hasattr(item, "target"): target = item.target title = getattr(item, "title", None) elif isinstance(item, str): target = item if target: if title: # Use memory:// M3U hack to pass title safe_title = title.replace('\n', ' ').replace('\r', '') m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" cmd.append(f"memory://{m3u_content}") else: cmd.append(target) if len(cmd) > 3: # mpv + ipc + format + at least one file try: kwargs = {} if platform.system() == 'Windows': kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs) log(f"Started MPV with {len(cmd)-3} items") except Exception as e: log(f"Error starting MPV: {e}", file=sys.stderr) CMDLET = Cmdlet( name=".pipe", aliases=["pipe", "playlist", "queue", "ls-pipe"], summary="Manage and play items in the MPV playlist via IPC", usage=".pipe [index] [-clear]", args=[ CmdletArg( name="index", type="int", description="Index of item to play or clear", required=False ), CmdletArg( name="clear", type="flag", description="Remove the selected item from the playlist" ), CmdletArg( name="list", type="flag", description="List items (default)" ), CmdletArg( name="play", type="flag", description="Resume playback" ), CmdletArg( name="pause", type="flag", description="Pause playback" ), ], exec=_run )