2025-11-25 20:09:33 -08:00
|
|
|
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
|
2025-11-26 00:02:33 -08:00
|
|
|
from helper.logger import log, debug
|
2025-11-25 20:09:33 -08:00
|
|
|
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:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Windows IPC Error: {e}", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
return None
|
|
|
|
|
else:
|
|
|
|
|
# Unix socket
|
|
|
|
|
af_unix = getattr(socket, 'AF_UNIX', None)
|
|
|
|
|
if af_unix is None:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("Unix sockets not supported on this platform", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
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:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Unix IPC Error: {e}", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"IPC Error: {e}", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
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")
|
2025-11-25 22:34:41 -08:00
|
|
|
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":
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("Resumed playback")
|
2025-11-25 22:34:41 -08:00
|
|
|
return 0
|
|
|
|
|
else:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("Failed to resume playback (MPV not running?)", file=sys.stderr)
|
2025-11-25 22:34:41 -08:00
|
|
|
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":
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("Paused playback")
|
2025-11-25 22:34:41 -08:00
|
|
|
return 0
|
|
|
|
|
else:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("Failed to pause playback (MPV not running?)", file=sys.stderr)
|
2025-11-25 22:34:41 -08:00
|
|
|
return 1
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
2025-11-25 22:34:41 -08:00
|
|
|
# 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
|
2025-11-25 20:09:33 -08:00
|
|
|
if title:
|
2025-11-25 22:34:41 -08:00
|
|
|
# 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
|
2025-11-25 20:09:33 -08:00
|
|
|
|
2025-11-25 22:34:41 -08:00
|
|
|
cmd = {"command": ["loadfile", target_to_send, "append"], "request_id": 200}
|
2025-11-25 20:09:33 -08:00
|
|
|
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:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Queued: {title}")
|
2025-11-25 20:09:33 -08:00
|
|
|
else:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Queued: {target}")
|
2025-11-25 22:34:41 -08:00
|
|
|
else:
|
|
|
|
|
error_msg = str(resp.get('error'))
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Failed to queue item: {error_msg}", file=sys.stderr)
|
2025-11-25 22:34:41 -08:00
|
|
|
|
|
|
|
|
# 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
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Queued (fallback): {title or target}")
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
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:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("MPV playlist is empty or MPV is not running.")
|
2025-11-25 20:09:33 -08:00
|
|
|
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):
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Index {index_arg} out of range (1-{len(items)}).")
|
2025-11-25 20:09:33 -08:00
|
|
|
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":
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Removed: {title}")
|
2025-11-25 20:09:33 -08:00
|
|
|
# Refresh items for listing
|
|
|
|
|
items = _get_playlist()
|
|
|
|
|
list_mode = True
|
|
|
|
|
index_arg = None
|
|
|
|
|
else:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Failed to remove item: {resp.get('error') if resp else 'No response'}")
|
2025-11-25 20:09:33 -08:00
|
|
|
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":
|
2025-11-25 22:34:41 -08:00
|
|
|
# Ensure playback starts (unpause)
|
|
|
|
|
unpause_cmd = {"command": ["set_property", "pause", False], "request_id": 103}
|
|
|
|
|
_send_ipc_command(unpause_cmd)
|
|
|
|
|
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Playing: {title}")
|
2025-11-25 20:09:33 -08:00
|
|
|
return 0
|
|
|
|
|
else:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
|
2025-11-25 20:09:33 -08:00
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
except ValueError:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Invalid index: {index_arg}")
|
2025-11-25 20:09:33 -08:00
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
# List items (Default action or after clear)
|
|
|
|
|
if list_mode or index_arg is None:
|
|
|
|
|
if not items:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug("MPV playlist is empty.")
|
2025-11-25 20:09:33 -08:00
|
|
|
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:
|
2025-11-25 22:34:41 -08:00
|
|
|
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)
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
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)
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Started MPV with {len(cmd)-3} items")
|
2025-11-25 20:09:33 -08:00
|
|
|
except Exception as e:
|
2025-11-26 00:02:33 -08:00
|
|
|
debug(f"Error starting MPV: {e}", file=sys.stderr)
|
2025-11-25 20:09:33 -08:00
|
|
|
|
|
|
|
|
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)"
|
|
|
|
|
),
|
2025-11-25 22:34:41 -08:00
|
|
|
CmdletArg(
|
|
|
|
|
name="play",
|
|
|
|
|
type="flag",
|
|
|
|
|
description="Resume playback"
|
|
|
|
|
),
|
|
|
|
|
CmdletArg(
|
|
|
|
|
name="pause",
|
|
|
|
|
type="flag",
|
|
|
|
|
description="Pause playback"
|
|
|
|
|
),
|
2025-11-25 20:09:33 -08:00
|
|
|
],
|
|
|
|
|
exec=_run
|
|
|
|
|
)
|
|
|
|
|
|