df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-29 17:05:03 -08:00
parent 226de9316a
commit c019c00aed
104 changed files with 19669 additions and 12954 deletions

View File

@@ -18,7 +18,6 @@ from API.folder import LocalLibrarySearchOptimizer
from config import get_local_storage_path, get_hydrus_access_key, get_hydrus_url
_ALLDEBRID_UNLOCK_CACHE: Dict[str, str] = {}
@@ -69,10 +68,14 @@ def _try_enable_mpv_file_logging(mpv_log_path: str, *, attempts: int = 3) -> boo
# Try to set log-file and verbose level.
r1 = _send_ipc_command({"command": ["set_property", "options/log-file", mpv_log_path]})
r2 = _send_ipc_command({"command": ["set_property", "options/msg-level", "all=v"]})
ok = bool((r1 and r1.get("error") == "success") or (r2 and r2.get("error") == "success"))
ok = bool(
(r1 and r1.get("error") == "success") or (r2 and r2.get("error") == "success")
)
# Emit a predictable line so the file isn't empty if logging is active.
_send_ipc_command({"command": ["print-text", f"medeia: log enabled -> {mpv_log_path}"]}, silent=True)
_send_ipc_command(
{"command": ["print-text", f"medeia: log enabled -> {mpv_log_path}"]}, silent=True
)
except Exception:
ok = False
@@ -86,6 +89,7 @@ def _try_enable_mpv_file_logging(mpv_log_path: str, *, attempts: int = 3) -> boo
try:
import time
time.sleep(0.15)
except Exception:
break
@@ -168,6 +172,7 @@ def _ensure_lyric_overlay(mpv: MPV) -> None:
except Exception:
pass
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:
@@ -189,11 +194,12 @@ def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
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:
@@ -204,18 +210,18 @@ def _extract_title_from_item(item: Dict[str, Any]) -> str:
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://'):
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"
@@ -225,7 +231,7 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
return None
for line in text.splitlines():
line = line.strip()
if not line or line.startswith('#') or line.startswith('memory://'):
if not line or line.startswith("#") or line.startswith("memory://"):
continue
return line
return None
@@ -233,11 +239,11 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
def _find_hydrus_instance_for_hash(hash_str: str, file_storage: Any) -> Optional[str]:
"""Find which Hydrus instance serves a specific file hash.
Args:
hash_str: SHA256 hash (64 hex chars)
file_storage: FileStorage instance with Hydrus backends
Returns:
Instance name (e.g., 'home') or None if not found
"""
@@ -248,7 +254,7 @@ def _find_hydrus_instance_for_hash(hash_str: str, file_storage: Any) -> Optional
backend_class = type(backend).__name__
if backend_class != "HydrusNetwork":
continue
try:
# Query metadata to see if this instance has the file
metadata = backend.get_metadata(hash_str)
@@ -257,44 +263,44 @@ def _find_hydrus_instance_for_hash(hash_str: str, file_storage: Any) -> Optional
except Exception:
# This instance doesn't have the file or had an error
continue
return None
def _find_hydrus_instance_by_url(url: str, file_storage: Any) -> Optional[str]:
"""Find which Hydrus instance matches a given URL.
Args:
url: Full URL (e.g., http://localhost:45869/get_files/file?hash=...)
file_storage: FileStorage instance with Hydrus backends
Returns:
Instance name (e.g., 'home') or None if not found
"""
from urllib.parse import urlparse
parsed_target = urlparse(url)
target_netloc = parsed_target.netloc.lower()
# Check each Hydrus backend's URL
for backend_name in file_storage.list_backends():
backend = file_storage[backend_name]
backend_class = type(backend).__name__
if backend_class != "HydrusNetwork":
continue
# Get the backend's base URL from its client
try:
backend_url = backend._client.base_url
parsed_backend = urlparse(backend_url)
backend_netloc = parsed_backend.netloc.lower()
# Match by netloc (host:port)
if target_netloc == backend_netloc:
return backend_name
except Exception:
continue
return None
@@ -324,17 +330,19 @@ def _normalize_playlist_path(text: Optional[str]) -> Optional[str]:
pass
# Normalize slashes for Windows paths and lowercase for comparison
real = real.replace('\\', '/')
real = real.replace("\\", "/")
return real.lower()
def _infer_store_from_playlist_item(item: Dict[str, Any], file_storage: Optional[Any] = None) -> str:
def _infer_store_from_playlist_item(
item: Dict[str, Any], file_storage: Optional[Any] = None
) -> str:
"""Infer a friendly store label from an MPV playlist entry.
Args:
item: MPV playlist item dict
file_storage: Optional FileStorage instance for querying specific backend instances
Returns:
Store label (e.g., 'home', 'work', 'local', 'youtube', etc.)
"""
@@ -423,7 +431,7 @@ def _infer_store_from_playlist_item(item: Dict[str, Any], file_storage: Optional
return hydrus_instance
return "hydrus"
parts = host_stripped.split('.')
parts = host_stripped.split(".")
if len(parts) >= 2:
return parts[-2] or host_stripped
return host_stripped
@@ -440,7 +448,9 @@ def _build_hydrus_header(config: Dict[str, Any]) -> Optional[str]:
return f"Hydrus-Client-API-Access-Key: {key}"
def _build_ytdl_options(config: Optional[Dict[str, Any]], hydrus_header: Optional[str]) -> Optional[str]:
def _build_ytdl_options(
config: Optional[Dict[str, Any]], hydrus_header: Optional[str]
) -> Optional[str]:
"""Compose ytdl-raw-options string including cookies and optional Hydrus header."""
opts: List[str] = []
cookies_path = None
@@ -454,7 +464,7 @@ def _build_ytdl_options(config: Optional[Dict[str, Any]], hydrus_header: Optiona
cookies_path = None
if cookies_path:
opts.append(f"cookies={cookies_path.replace('\\', '/')}" )
opts.append(f"cookies={cookies_path.replace('\\', '/')}")
else:
opts.append("cookies-from-browser=chrome")
if hydrus_header:
@@ -484,9 +494,11 @@ def _is_hydrus_path(path: str, hydrus_url: Optional[str]) -> bool:
return True
return False
def _ensure_ytdl_cookies(config: Optional[Dict[str, Any]] = None) -> None:
"""Ensure yt-dlp options are set correctly for this session."""
from pathlib import Path
cookies_path = None
try:
from tool.ytdlp import YtDlpTool
@@ -498,7 +510,7 @@ def _ensure_ytdl_cookies(config: Optional[Dict[str, Any]] = None) -> None:
cookies_path = None
if cookies_path:
# Check if file exists and has content (use forward slashes for path checking)
check_path = cookies_path.replace('\\', '/')
check_path = cookies_path.replace("\\", "/")
file_obj = Path(cookies_path)
if file_obj.exists():
file_size = file_obj.stat().st_size
@@ -508,6 +520,7 @@ def _ensure_ytdl_cookies(config: Optional[Dict[str, Any]] = None) -> None:
else:
debug("No cookies file configured")
def _monitor_mpv_logs(duration: float = 3.0) -> None:
"""Monitor MPV logs for a short duration to capture errors."""
try:
@@ -516,16 +529,17 @@ def _monitor_mpv_logs(duration: float = 3.0) -> None:
if not client.connect():
debug("Failed to connect to MPV for log monitoring", file=sys.stderr)
return
# Request log messages
client.send_command({"command": ["request_log_messages", "warn"]})
# On Windows named pipes, avoid blocking the CLI; skip log read entirely
if client.is_windows:
client.disconnect()
return
import time
start_time = time.time()
# Unix sockets already have timeouts set; read until duration expires
@@ -585,14 +599,18 @@ def _tail_text_file(path: str, *, max_lines: int = 120, max_bytes: int = 65536)
return lines
except Exception:
return []
def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[Dict[str, Any]]) -> Optional[tuple[str, Optional[str]]]:
def _get_playable_path(
item: Any, file_storage: Optional[Any], config: Optional[Dict[str, Any]]
) -> Optional[tuple[str, Optional[str]]]:
"""Extract a playable path/URL from an item, handling different store types.
Args:
item: Item to extract path from (dict, PipeObject, or string)
file_storage: FileStorage instance for querying backends
config: Config dict for Hydrus URL
Returns:
Tuple of (path, title) or None if no valid path found
"""
@@ -600,7 +618,7 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
title: Optional[str] = None
store: Optional[str] = None
file_hash: Optional[str] = None
# Extract fields from item - prefer a disk path ('path'), but accept 'url' as fallback for providers
if isinstance(item, dict):
path = item.get("path")
@@ -614,13 +632,25 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
title = item.get("title") or item.get("file_title")
store = item.get("store")
file_hash = item.get("hash")
elif hasattr(item, "path") or hasattr(item, "url") or hasattr(item, "source_url") or hasattr(item, "store") or hasattr(item, "hash"):
elif (
hasattr(item, "path")
or hasattr(item, "url")
or hasattr(item, "source_url")
or hasattr(item, "store")
or hasattr(item, "hash")
):
# Handle PipeObject / dataclass objects - prefer path, but fall back to url/source_url attributes
path = getattr(item, "path", None)
if not path:
path = getattr(item, "url", None) or getattr(item, "source_url", None) or getattr(item, "target", None)
path = (
getattr(item, "url", None)
or getattr(item, "source_url", None)
or getattr(item, "target", None)
)
if not path:
known = getattr(item, "url", None) or (getattr(item, "extra", None) or {}).get("url")
known = getattr(item, "url", None) or (getattr(item, "extra", None) or {}).get(
"url"
)
if known and isinstance(known, list):
path = known[0]
title = getattr(item, "title", None) or getattr(item, "file_title", None)
@@ -628,13 +658,13 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
file_hash = getattr(item, "hash", None)
elif isinstance(item, str):
path = item
# Debug: show incoming values
try:
debug(f"_get_playable_path: store={store}, path={path}, hash={file_hash}")
except Exception:
pass
# Treat common placeholders as missing.
if isinstance(path, str) and path.strip().lower() in {"", "n/a", "na", "none"}:
path = None
@@ -644,7 +674,7 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
if isinstance(file_hash, str):
file_hash = file_hash.strip().lower()
# Resolve hash+store into a playable target (file path or URL).
# This is unrelated to MPV's IPC pipe and keeps "pipe" terminology reserved for:
# - MPV IPC pipe (transport)
@@ -663,7 +693,11 @@ def _get_playable_path(item: Any, file_storage: Optional[Any], config: Optional[
backend_class = type(backend).__name__
# Folder stores: resolve to an on-disk file path.
if hasattr(backend, "get_file") and callable(getattr(backend, "get_file")) and backend_class == "Folder":
if (
hasattr(backend, "get_file")
and callable(getattr(backend, "get_file"))
and backend_class == "Folder"
):
try:
resolved = backend.get_file(file_hash)
if isinstance(resolved, Path):
@@ -705,11 +739,11 @@ def _queue_items(
start_opts: Optional[Dict[str, Any]] = None,
) -> bool:
"""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
Returns:
True if MPV was started, False if items were queued via IPC.
"""
@@ -729,11 +763,12 @@ def _queue_items(
hydrus_url = get_hydrus_url(config) if config is not None else None
except Exception:
hydrus_url = None
# Initialize Store registry for path resolution
file_storage = None
try:
from Store import Store
file_storage = Store(config or {})
except Exception as e:
debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
@@ -757,7 +792,9 @@ def _queue_items(
# Remove duplicates from playlist starting from the end to keep indices valid
for idx in reversed(dup_indexes):
try:
_send_ipc_command({"command": ["playlist-remove", idx], "request_id": 106}, silent=True)
_send_ipc_command(
{"command": ["playlist-remove", idx], "request_id": 106}, silent=True
)
except Exception:
pass
@@ -774,7 +811,7 @@ def _queue_items(
if not result:
debug(f"_queue_items: item idx={i} produced no playable path")
continue
target, title = result
# If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV.
@@ -812,15 +849,19 @@ def _queue_items(
if base_url:
effective_hydrus_url = str(base_url).rstrip("/")
if key:
effective_hydrus_header = f"Hydrus-Client-API-Access-Key: {str(key).strip()}"
effective_hydrus_header = (
f"Hydrus-Client-API-Access-Key: {str(key).strip()}"
)
effective_ytdl_opts = _build_ytdl_options(config, effective_hydrus_header)
except Exception:
pass
if target:
# If we just have a hydrus hash, build a direct file URL for MPV
if re.fullmatch(r"[0-9a-f]{64}", str(target).strip().lower()) and effective_hydrus_url:
target = f"{effective_hydrus_url.rstrip('/')}/get_files/file?hash={str(target).strip()}"
target = (
f"{effective_hydrus_url.rstrip('/')}/get_files/file?hash={str(target).strip()}"
)
norm_key = _normalize_playlist_path(target) or str(target).strip().lower()
if norm_key in existing_targets or norm_key in new_targets:
@@ -833,13 +874,17 @@ def _queue_items(
# show the raw URL as the playlist title.
if title:
# Sanitize title for M3U (remove newlines)
safe_title = title.replace('\n', ' ').replace('\r', '')
safe_title = title.replace("\n", " ").replace("\r", "")
# Carry the store name for hash URLs so MPV.lyric can resolve the backend.
# This is especially important for local file-server URLs like /get_files/file?hash=...
target_for_m3u = target
try:
if item_store_name and isinstance(target_for_m3u, str) and target_for_m3u.startswith("http"):
if (
item_store_name
and isinstance(target_for_m3u, str)
and target_for_m3u.startswith("http")
):
if "get_files/file" in target_for_m3u and "store=" not in target_for_m3u:
sep = "&" if "?" in target_for_m3u else "?"
target_for_m3u = f"{target_for_m3u}{sep}store={item_store_name}"
@@ -858,10 +903,16 @@ def _queue_items(
# If this is a Hydrus path, set header property and yt-dlp headers before loading.
# Use the real target (not the memory:// wrapper) for detection.
if effective_hydrus_header and _is_hydrus_path(str(target), effective_hydrus_url):
header_cmd = {"command": ["set_property", "http-header-fields", effective_hydrus_header], "request_id": 199}
header_cmd = {
"command": ["set_property", "http-header-fields", effective_hydrus_header],
"request_id": 199,
}
_send_ipc_command(header_cmd, silent=True)
if effective_ytdl_opts:
ytdl_cmd = {"command": ["set_property", "ytdl-raw-options", effective_ytdl_opts], "request_id": 197}
ytdl_cmd = {
"command": ["set_property", "ytdl-raw-options", effective_ytdl_opts],
"request_id": 197,
}
_send_ipc_command(ytdl_cmd, silent=True)
cmd = {"command": ["loadfile", target_to_send, mode], "request_id": 200}
@@ -872,11 +923,13 @@ def _queue_items(
except Exception as e:
debug(f"Exception sending loadfile to MPV: {e}", file=sys.stderr)
resp = None
if resp is None:
# MPV not running (or died)
# Start MPV with remaining items
debug(f"MPV not running/died while queuing, starting MPV with remaining items: {items[i:]}")
debug(
f"MPV not running/died while queuing, starting MPV with remaining items: {items[i:]}"
)
_start_mpv(items[i:], config=config, start_opts=start_opts)
return True
elif resp.get("error") == "success":
@@ -884,13 +937,14 @@ def _queue_items(
# would change the MPV window title even if the item isn't currently playing.
debug(f"Queued: {title or target}")
else:
error_msg = str(resp.get('error'))
error_msg = str(resp.get("error"))
debug(f"Failed to queue item: {error_msg}", file=sys.stderr)
return False
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)
log_requested = bool(parsed.get("log"))
@@ -912,7 +966,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log_dir = _repo_log_dir()
mpv_log_path = str((log_dir / "medeia-mpv.log").resolve())
except Exception:
mpv_log_path = str((Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "medeia-mpv.log").resolve())
mpv_log_path = str(
(
Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".")
/ "medeia-mpv.log"
).resolve()
)
# Ensure file exists early so we can tail it even if mpv writes later.
try:
Path(mpv_log_path).parent.mkdir(parents=True, exist_ok=True)
@@ -1019,11 +1078,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Emit the current item to pipeline
result_obj = {
'path': filename,
'title': title,
'cmdlet_name': '.pipe',
'source': 'pipe',
'__pipe_index': items.index(current_item),
"path": filename,
"title": title,
"cmdlet_name": ".pipe",
"source": "pipe",
"__pipe_index": items.index(current_item),
}
ctx.emit(result_obj)
@@ -1040,6 +1099,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if mpv_started:
# MPV was just started, wait a moment for it to be ready, then play first item
import time
time.sleep(0.5)
index_arg = "1" # 1-based index for first item
play_mode = True
@@ -1061,6 +1121,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if mpv_started:
# MPV was just started; give it a moment, then play first item.
import time
time.sleep(0.5)
index_arg = "1"
else:
@@ -1156,7 +1217,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Queue items (replacing current playlist)
if items:
_queue_items(items, clear_first=True, config=config, start_opts=start_opts)
_queue_items(
items, clear_first=True, config=config, start_opts=start_opts
)
else:
# Empty playlist, just clear
_send_ipc_command({"command": ["playlist-clear"]}, silent=True)
@@ -1180,22 +1243,22 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
table = ResultTable("Saved Playlists")
for i, pl in enumerate(playlists):
item_count = len(pl.get('items', []))
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("Name", pl["name"])
row.add_column("Items", str(item_count))
row.add_column("Updated", pl['updated_at'])
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_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_last_result_table_overlay(table, [p["items"] for p in playlists])
ctx.set_current_stage_table(table)
# Do not print directly here.
@@ -1243,7 +1306,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
playlist_before = _get_playlist(silent=True)
idle_before = None
try:
idle_resp = _send_ipc_command({"command": ["get_property", "idle-active"], "request_id": 111}, silent=True)
idle_resp = _send_ipc_command(
{"command": ["get_property", "idle-active"], "request_id": 111}, silent=True
)
if idle_resp and idle_resp.get("error") == "success":
idle_before = bool(idle_resp.get("data"))
except Exception:
@@ -1262,7 +1327,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Debug: inspect incoming result and attributes
try:
debug(f"pipe._run: received result type={type(result)} repr={repr(result)[:200]}")
debug(f"pipe._run: attrs path={getattr(result, 'path', None)} url={getattr(result, 'url', None)} store={getattr(result, 'store', None)} hash={getattr(result, 'hash', None)}")
debug(
f"pipe._run: attrs path={getattr(result, 'path', None)} url={getattr(result, 'url', None)} store={getattr(result, 'store', None)} hash={getattr(result, 'hash', None)}"
)
except Exception:
pass
@@ -1294,8 +1361,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if should_autoplay and after_len > 0:
idx_to_play = min(max(0, before_len), after_len - 1)
play_resp = _send_ipc_command({"command": ["playlist-play-index", idx_to_play], "request_id": 112}, silent=True)
_send_ipc_command({"command": ["set_property", "pause", False], "request_id": 113}, silent=True)
play_resp = _send_ipc_command(
{"command": ["playlist-play-index", idx_to_play], "request_id": 112},
silent=True,
)
_send_ipc_command(
{"command": ["set_property", "pause", False], "request_id": 113},
silent=True,
)
if play_resp and play_resp.get("error") == "success":
debug("Auto-playing piped item")
@@ -1315,6 +1388,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if mpv_started:
# MPV was just started, retry getting playlist after a brief delay
import time
time.sleep(0.3)
items = _get_playlist(silent=True)
@@ -1324,10 +1398,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 0
else:
# Do not auto-launch MPV when no action/inputs were provided; avoid surprise startups
no_inputs = not any([
result, url_arg, index_arg, clear_mode, play_mode,
pause_mode, save_mode, load_mode, current_mode, list_mode
])
no_inputs = not any(
[
result,
url_arg,
index_arg,
clear_mode,
play_mode,
pause_mode,
save_mode,
load_mode,
current_mode,
list_mode,
]
)
if no_inputs:
# User invoked `.pipe` with no args: treat this as an intent to open MPV.
@@ -1337,6 +1421,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Re-check playlist after startup; if IPC still isn't ready, just exit cleanly.
try:
import time
time.sleep(0.3)
except Exception:
pass
@@ -1386,18 +1471,26 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
list_mode = True
index_arg = None
else:
debug(f"Failed to remove item: {resp.get('error') if resp else 'No response'}")
debug(
f"Failed to remove item: {resp.get('error') if resp else 'No response'}"
)
return 1
else:
# Play item
if hydrus_header and _is_hydrus_path(filename, hydrus_url):
header_cmd = {"command": ["set_property", "http-header-fields", hydrus_header], "request_id": 198}
header_cmd = {
"command": ["set_property", "http-header-fields", hydrus_header],
"request_id": 198,
}
_send_ipc_command(header_cmd, silent=True)
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}
unpause_cmd = {
"command": ["set_property", "pause", False],
"request_id": 103,
}
_send_ipc_command(unpause_cmd)
debug(f"Playing: {title}")
@@ -1410,7 +1503,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
list_mode = True
index_arg = None
else:
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
debug(
f"Failed to play item: {resp.get('error') if resp else 'No response'}"
)
return 1
except ValueError:
debug(f"Invalid index: {index_arg}")
@@ -1425,6 +1520,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if file_storage is None:
try:
from Store import Store
file_storage = Store(config)
except Exception as e:
debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
@@ -1468,7 +1564,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Try to extract hash from filename (e.g., C:\path\1e8c46...a1b2.mp4)
path_obj = Path(real_path)
stem = path_obj.stem # filename without extension
if len(stem) == 64 and all(c in '0123456789abcdef' for c in stem.lower()):
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
file_hash = stem.lower()
# Find which folder store has this file
if file_storage:
@@ -1493,7 +1589,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
hash=file_hash or "unknown",
store=store_name or "unknown",
title=title,
path=real_path
path=real_path,
)
pipe_objects.append(pipe_obj)
@@ -1540,6 +1636,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
break
try:
import time
time.sleep(0.25)
except Exception:
break
@@ -1550,8 +1647,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
print(ln)
else:
print("MPV log (tail): <empty>")
print("Note: On some Windows builds, mpv cannot start writing to --log-file after launch.")
print("If you need full [main2] logs, restart mpv so it starts with --log-file.")
print(
"Note: On some Windows builds, mpv cannot start writing to --log-file after launch."
)
print(
"If you need full [main2] logs, restart mpv so it starts with --log-file."
)
# Also print the helper log tail (this captures Python helper output that won't
# necessarily show up in MPV's own log-file).
@@ -1597,7 +1698,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception:
pass
def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None, start_opts: Optional[Dict[str, Any]] = None) -> None:
def _start_mpv(
items: List[Any],
config: Optional[Dict[str, Any]] = None,
start_opts: Optional[Dict[str, Any]] = None,
) -> None:
"""Start MPV with a list of items."""
import time as _time_module
@@ -1624,7 +1730,7 @@ def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None, start_
try:
extra_args: List[str] = [
'--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]',
"--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]",
]
# Optional: borderless window (useful for uosc-like overlay UI without fullscreen).
@@ -1645,7 +1751,7 @@ def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None, start_
detached=True,
)
debug("Started MPV process")
# Wait for IPC pipe to be ready
if not mpv.wait_for_ipc(retries=20, delay_seconds=0.2):
debug("Timed out waiting for MPV IPC connection", file=sys.stderr)
@@ -1659,15 +1765,16 @@ def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None, start_
# Queue items via IPC
if items:
_queue_items(items, config=config, start_opts=start_opts)
# Auto-play the first item
import time
time.sleep(0.3) # Give MPV a moment to process the queued items
# Play the first item (index 0) and unpause
play_cmd = {"command": ["playlist-play-index", 0], "request_id": 102}
play_resp = _send_ipc_command(play_cmd, silent=True)
if play_resp and play_resp.get("error") == "success":
# Ensure playback starts (unpause)
unpause_cmd = {"command": ["set_property", "pause", False], "request_id": 103}
@@ -1675,7 +1782,7 @@ def _start_mpv(items: List[Any], config: Optional[Dict[str, Any]] = None, start_
debug("Auto-playing first item")
# Overlay already started above; it will follow track changes automatically.
except Exception as e:
debug(f"Error starting MPV: {e}", file=sys.stderr)
@@ -1688,36 +1795,19 @@ CMDLET = Cmdlet(
arg=[
CmdletArg(
name="index",
type="string", # Changed to string to allow URL detection
type="string", # Changed to string to allow URL detection
description="Index of item to play/clear, or URL to queue",
required=False
),
CmdletArg(
name="url",
type="string",
description="URL to queue",
required=False
required=False,
),
CmdletArg(name="url", type="string", description="URL to queue", required=False),
CmdletArg(
name="clear",
type="flag",
description="Remove the selected item, or clear entire playlist if no index provided"
),
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"
description="Remove the selected item, or clear entire playlist if no index provided",
),
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"),
CmdletArg(
name="save",
type="flag",
@@ -1733,19 +1823,18 @@ CMDLET = Cmdlet(
CmdletArg(
name="current",
type="flag",
description="Emit the currently playing item to pipeline for further processing"
description="Emit the currently playing item to pipeline for further processing",
),
CmdletArg(
name="log",
type="flag",
description="Enable pipeable debug output and write an mpv log file"
description="Enable pipeable debug output and write an mpv log file",
),
CmdletArg(
name="borderless",
type="flag",
description="Start mpv with no window border (uosc-like overlay feel without fullscreen)"
description="Start mpv with no window border (uosc-like overlay feel without fullscreen)",
),
],
exec=_run
exec=_run,
)