jjlj
This commit is contained in:
445
cmdlets/pipe.py
445
cmdlets/pipe.py
@@ -8,92 +8,124 @@ import subprocess
|
||||
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
|
||||
from helper.logger import log, debug
|
||||
from result_table import ResultTable
|
||||
from .get_file import _get_fixed_ipc_pipe
|
||||
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
|
||||
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:
|
||||
debug(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:
|
||||
debug("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:
|
||||
debug(f"Unix IPC Error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
debug(f"IPC Error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return None
|
||||
from helper.local_library import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
|
||||
def _get_playlist() -> List[Dict[str, Any]]:
|
||||
"""Get the current playlist from MPV."""
|
||||
def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]:
|
||||
"""Send a command to the MPV IPC pipe and return the response."""
|
||||
try:
|
||||
ipc_pipe = get_ipc_pipe_path()
|
||||
client = MPVIPCClient(socket_path=ipc_pipe)
|
||||
|
||||
if not client.connect():
|
||||
return None # MPV not running
|
||||
|
||||
response = client.send_command(command)
|
||||
client.disconnect()
|
||||
return response
|
||||
except Exception as e:
|
||||
if not silent:
|
||||
debug(f"IPC Error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get the current playlist from MPV. Returns None if MPV is not running."""
|
||||
cmd = {"command": ["get_property", "playlist"], "request_id": 100}
|
||||
resp = _send_ipc_command(cmd)
|
||||
if resp and resp.get("error") == "success":
|
||||
resp = _send_ipc_command(cmd, silent=silent)
|
||||
if resp is None:
|
||||
return None
|
||||
if resp.get("error") == "success":
|
||||
return resp.get("data", [])
|
||||
return []
|
||||
|
||||
def _extract_title_from_item(item: Dict[str, Any]) -> str:
|
||||
"""Extract a clean title from an MPV playlist item, handling memory:// M3U hacks."""
|
||||
title = item.get("title")
|
||||
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
|
||||
|
||||
# If we still don't have a title, try to find the URL in the M3U content
|
||||
if not title:
|
||||
lines = filename.splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and not line.startswith('memory://'):
|
||||
# Found the URL, use it as title
|
||||
return line
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return title or filename or "Unknown"
|
||||
|
||||
def _queue_items(items: List[Any], clear_first: bool = False) -> None:
|
||||
"""Queue items to MPV, starting it if necessary.
|
||||
|
||||
Args:
|
||||
items: List of items to queue
|
||||
clear_first: If True, the first item will replace the current playlist
|
||||
"""
|
||||
for i, item in enumerate(items):
|
||||
# Extract URL/Path
|
||||
target = None
|
||||
title = None
|
||||
|
||||
if isinstance(item, dict):
|
||||
target = item.get("target") or item.get("url") or item.get("path") or item.get("filename")
|
||||
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 (or replace if clear_first is set)
|
||||
|
||||
# Use memory:// M3U hack to pass title to MPV
|
||||
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
|
||||
|
||||
mode = "append"
|
||||
if clear_first and i == 0:
|
||||
mode = "replace"
|
||||
|
||||
cmd = {"command": ["loadfile", target_to_send, mode], "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[i:])
|
||||
return
|
||||
elif resp.get("error") == "success":
|
||||
# Also set property for good measure
|
||||
if title:
|
||||
title_cmd = {"command": ["set_property", "force-media-title", title], "request_id": 201}
|
||||
_send_ipc_command(title_cmd)
|
||||
debug(f"Queued: {title or target}")
|
||||
else:
|
||||
error_msg = str(resp.get('error'))
|
||||
debug(f"Failed to queue item: {error_msg}", file=sys.stderr)
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Manage and play items in the MPV playlist via IPC."""
|
||||
|
||||
@@ -106,7 +138,115 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
list_mode = parsed.get("list")
|
||||
play_mode = parsed.get("play")
|
||||
pause_mode = parsed.get("pause")
|
||||
save_mode = parsed.get("save")
|
||||
load_mode = parsed.get("load")
|
||||
|
||||
# Handle Save Playlist
|
||||
if save_mode:
|
||||
playlist_name = index_arg or f"Playlist {subprocess.check_output(['date', '/t'], shell=True).decode().strip()}"
|
||||
# If index_arg was used for name, clear it so it doesn't trigger index logic
|
||||
if index_arg:
|
||||
index_arg = None
|
||||
|
||||
items = _get_playlist()
|
||||
if not items:
|
||||
debug("Cannot save: MPV playlist is empty or MPV is not running.")
|
||||
return 1
|
||||
|
||||
# Clean up items for saving (remove current flag, etc)
|
||||
clean_items = []
|
||||
for item in items:
|
||||
# If title was extracted from memory://, we should probably save the original filename
|
||||
# if it's a URL, or reconstruct a clean object.
|
||||
# Actually, _extract_title_from_item handles the display title.
|
||||
# But for playback, we need the 'filename' (which might be memory://...)
|
||||
# If we save 'memory://...', it will work when loaded back.
|
||||
clean_items.append(item)
|
||||
|
||||
# Use config from context or load it
|
||||
config_data = config if config else {}
|
||||
|
||||
storage_path = get_local_storage_path(config_data)
|
||||
if not storage_path:
|
||||
debug("Local storage path not configured.")
|
||||
return 1
|
||||
|
||||
with LocalLibrarySearchOptimizer(storage_path) as db:
|
||||
if db.save_playlist(playlist_name, clean_items):
|
||||
debug(f"Playlist saved as '{playlist_name}'")
|
||||
return 0
|
||||
else:
|
||||
debug(f"Failed to save playlist '{playlist_name}'")
|
||||
return 1
|
||||
|
||||
# Handle Load Playlist
|
||||
current_playlist_name = None
|
||||
if load_mode:
|
||||
# Use config from context or load it
|
||||
config_data = config if config else {}
|
||||
|
||||
storage_path = get_local_storage_path(config_data)
|
||||
if not storage_path:
|
||||
debug("Local storage path not configured.")
|
||||
return 1
|
||||
|
||||
with LocalLibrarySearchOptimizer(storage_path) as db:
|
||||
if index_arg:
|
||||
try:
|
||||
pl_id = int(index_arg)
|
||||
result = db.get_playlist_by_id(pl_id)
|
||||
if result is None:
|
||||
debug(f"Playlist ID {pl_id} not found.")
|
||||
return 1
|
||||
|
||||
name, items = result
|
||||
current_playlist_name = name
|
||||
|
||||
# Queue items (replacing current playlist)
|
||||
if items:
|
||||
_queue_items(items, clear_first=True)
|
||||
else:
|
||||
# Empty playlist, just clear
|
||||
_send_ipc_command({"command": ["playlist-clear"]}, silent=True)
|
||||
|
||||
# Switch to list mode to show the result
|
||||
list_mode = True
|
||||
index_arg = None
|
||||
# Fall through to list logic
|
||||
|
||||
except ValueError:
|
||||
debug(f"Invalid playlist ID: {index_arg}")
|
||||
return 1
|
||||
else:
|
||||
playlists = db.get_playlists()
|
||||
|
||||
if not playlists:
|
||||
debug("No saved playlists found.")
|
||||
return 0
|
||||
|
||||
table = ResultTable("Saved Playlists")
|
||||
for i, pl in enumerate(playlists):
|
||||
item_count = len(pl.get('items', []))
|
||||
row = table.add_row()
|
||||
# row.add_column("ID", str(pl['id'])) # Hidden as per user request
|
||||
row.add_column("Name", pl['name'])
|
||||
row.add_column("Items", str(item_count))
|
||||
row.add_column("Updated", pl['updated_at'])
|
||||
|
||||
# Set the playlist items as the result object for this row
|
||||
# When user selects @N, they get the list of items
|
||||
# We also set the source command to .pipe -load <ID> so it loads it
|
||||
table.set_row_selection_args(i, ["-load", str(pl['id'])])
|
||||
|
||||
table.set_source_command(".pipe")
|
||||
|
||||
# Register results
|
||||
ctx.set_last_result_table_overlay(table, [p['items'] for p in playlists])
|
||||
ctx.set_current_stage_table(table)
|
||||
|
||||
print(table)
|
||||
return 0
|
||||
|
||||
# Handle Play/Pause commands
|
||||
if play_mode:
|
||||
cmd = {"command": ["set_property", "pause", False], "request_id": 103}
|
||||
@@ -148,64 +288,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
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:
|
||||
debug(f"Queued: {title}")
|
||||
else:
|
||||
debug(f"Queued: {target}")
|
||||
else:
|
||||
error_msg = str(resp.get('error'))
|
||||
debug(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
|
||||
debug(f"Queued (fallback): {title or target}")
|
||||
_queue_items(items_to_add)
|
||||
|
||||
if added_count > 0:
|
||||
if items_to_add:
|
||||
# If we added items, we might want to play the first one if nothing is playing?
|
||||
# For now, just list the playlist
|
||||
pass
|
||||
@@ -213,8 +298,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Get playlist from MPV
|
||||
items = _get_playlist()
|
||||
|
||||
if items is None:
|
||||
debug("MPV is not running. Starting new instance...")
|
||||
_start_mpv([])
|
||||
return 0
|
||||
|
||||
if not items:
|
||||
debug("MPV playlist is empty or MPV is not running.")
|
||||
debug("MPV playlist is empty.")
|
||||
return 0
|
||||
|
||||
# If index is provided, perform action (Play or Clear)
|
||||
@@ -228,7 +318,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 1
|
||||
|
||||
item = items[idx]
|
||||
title = item.get("title") or item.get("filename") or "Unknown"
|
||||
title = _extract_title_from_item(item)
|
||||
|
||||
if clear_mode:
|
||||
# Remove item
|
||||
@@ -237,7 +327,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if resp and resp.get("error") == "success":
|
||||
debug(f"Removed: {title}")
|
||||
# Refresh items for listing
|
||||
items = _get_playlist()
|
||||
items = _get_playlist() or []
|
||||
list_mode = True
|
||||
index_arg = None
|
||||
else:
|
||||
@@ -268,46 +358,26 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
debug("MPV playlist is empty.")
|
||||
return 0
|
||||
|
||||
table = ResultTable("MPV Playlist")
|
||||
# Use the loaded playlist name if available, otherwise default
|
||||
# Note: current_playlist_name is defined in the load_mode block if a playlist was loaded
|
||||
try:
|
||||
table_title = current_playlist_name or "MPV Playlist"
|
||||
except NameError:
|
||||
table_title = "MPV Playlist"
|
||||
|
||||
table = ResultTable(table_title)
|
||||
|
||||
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
|
||||
title = _extract_title_from_item(item)
|
||||
|
||||
# Truncate if too long
|
||||
if len(title) > 57:
|
||||
title = title[:57] + "..."
|
||||
if len(filename) > 27:
|
||||
filename = filename[:27] + "..."
|
||||
if len(title) > 80:
|
||||
title = title[:77] + "..."
|
||||
|
||||
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)])
|
||||
|
||||
@@ -323,9 +393,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
def _start_mpv(items: List[Any]) -> None:
|
||||
"""Start MPV with a list of items."""
|
||||
ipc_pipe = _get_fixed_ipc_pipe()
|
||||
ipc_pipe = get_ipc_pipe_path()
|
||||
|
||||
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}']
|
||||
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
|
||||
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
|
||||
|
||||
# Add items
|
||||
@@ -334,7 +404,7 @@ def _start_mpv(items: List[Any]) -> None:
|
||||
title = None
|
||||
|
||||
if isinstance(item, dict):
|
||||
target = item.get("target") or item.get("url") or item.get("path")
|
||||
target = item.get("target") or item.get("url") or item.get("path") or item.get("filename")
|
||||
title = item.get("title") or item.get("name")
|
||||
elif hasattr(item, "target"):
|
||||
target = item.target
|
||||
@@ -351,16 +421,15 @@ def _start_mpv(items: List[Any]) -> None:
|
||||
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)
|
||||
debug(f"Started MPV with {len(cmd)-3} items")
|
||||
except Exception as e:
|
||||
debug(f"Error starting MPV: {e}", file=sys.stderr)
|
||||
try:
|
||||
kwargs = {}
|
||||
if platform.system() == 'Windows':
|
||||
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
|
||||
|
||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
|
||||
debug(f"Started MPV with {len(items)} items")
|
||||
except Exception as e:
|
||||
debug(f"Error starting MPV: {e}", file=sys.stderr)
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".pipe",
|
||||
@@ -394,6 +463,16 @@ CMDLET = Cmdlet(
|
||||
type="flag",
|
||||
description="Pause playback"
|
||||
),
|
||||
CmdletArg(
|
||||
name="save",
|
||||
type="flag",
|
||||
description="Save current playlist to database"
|
||||
),
|
||||
CmdletArg(
|
||||
name="load",
|
||||
type="flag",
|
||||
description="List saved playlists"
|
||||
),
|
||||
],
|
||||
exec=_run
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user