This commit is contained in:
nose
2025-12-07 00:21:30 -08:00
parent f29709d951
commit 6b05dc5552
23 changed files with 2196 additions and 1133 deletions

View File

@@ -5,7 +5,7 @@ import platform
import socket
import re
import subprocess
from urllib.parse import urlparse
from urllib.parse import urlparse, parse_qs
from pathlib import Path
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug
@@ -87,6 +87,37 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
return None
def _normalize_playlist_target(text: Optional[str]) -> Optional[str]:
"""Normalize playlist entry targets for dedupe comparisons."""
if not text:
return None
real = _extract_target_from_memory_uri(text) or text
real = real.strip()
if not real:
return None
# If it's already a bare hydrus hash, use it directly
lower_real = real.lower()
if re.fullmatch(r"[0-9a-f]{64}", lower_real):
return lower_real
# If it's a hydrus file URL, normalize to the hash for dedupe
try:
parsed = urlparse(real)
if parsed.scheme in {"http", "https", "hydrus"}:
if parsed.path.endswith("/get_files/file"):
qs = parse_qs(parsed.query)
h = qs.get("hash", [None])[0]
if h and re.fullmatch(r"[0-9a-f]{64}", h.lower()):
return h.lower()
except Exception:
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]) -> str:
"""Infer a friendly store label from an MPV playlist entry."""
name = item.get("filename") if isinstance(item, dict) else None
@@ -97,6 +128,10 @@ def _infer_store_from_playlist_item(item: Dict[str, Any]) -> str:
if memory_target:
target = memory_target
# Hydrus hashes: bare 64-hex entries
if re.fullmatch(r"[0-9a-f]{64}", target.lower()):
return "hydrus"
lower = target.lower()
if lower.startswith("magnet:"):
return "magnet"
@@ -245,31 +280,36 @@ def _monitor_mpv_logs(duration: float = 3.0) -> None:
# 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
while time.time() - start_time < duration:
# We need to read raw lines from the socket
if client.is_windows:
try:
line = client.sock.readline()
if line:
try:
msg = json.loads(line)
if msg.get("event") == "log-message":
text = msg.get("text", "").strip()
prefix = msg.get("prefix", "")
level = msg.get("level", "")
if "ytdl" in prefix or level == "error":
debug(f"[MPV {prefix}] {text}", file=sys.stderr)
except json.JSONDecodeError:
pass
except Exception:
break
else:
# Unix socket handling (simplified)
try:
chunk = client.sock.recv(4096)
except socket.timeout:
continue
except Exception:
break
time.sleep(0.05)
if not chunk:
break
for line in chunk.decode("utf-8", errors="ignore").splitlines():
try:
msg = json.loads(line)
if msg.get("event") == "log-message":
text = msg.get("text", "").strip()
prefix = msg.get("prefix", "")
level = msg.get("level", "")
if "ytdl" in prefix or level == "error":
debug(f"[MPV {prefix}] {text}", file=sys.stderr)
except json.JSONDecodeError:
continue
client.disconnect()
except Exception:
pass
@@ -294,6 +334,31 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
except Exception:
hydrus_url = None
# Dedupe existing playlist before adding more (unless we're replacing it)
existing_targets: set[str] = set()
if not clear_first:
playlist = _get_playlist(silent=True) or []
dup_indexes: List[int] = []
for idx, pl_item in enumerate(playlist):
fname = pl_item.get("filename") if isinstance(pl_item, dict) else str(pl_item)
alt = pl_item.get("playlist-path") if isinstance(pl_item, dict) else None
norm = _normalize_playlist_target(fname) or _normalize_playlist_target(alt)
if not norm:
continue
if norm in existing_targets:
dup_indexes.append(idx)
else:
existing_targets.add(norm)
# 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)
except Exception:
pass
new_targets: set[str] = set()
for i, item in enumerate(items):
# Extract URL/Path
target = None
@@ -309,6 +374,16 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
target = item
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 hydrus_url:
target = f"{hydrus_url.rstrip('/')}/get_files/file?hash={str(target).strip()}"
norm_key = _normalize_playlist_target(target) or str(target).strip().lower()
if norm_key in existing_targets or norm_key in new_targets:
debug(f"Skipping duplicate playlist entry: {title or target}")
continue
new_targets.add(norm_key)
# Check if it's a yt-dlp supported URL
is_ytdlp = False
if target.startswith("http") and is_url_supported_by_ytdlp(target):
@@ -699,7 +774,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Monitor logs briefly for errors (e.g. ytdl failures)
_monitor_mpv_logs(3.0)
return 0
# Refresh playlist view so the user sees the new current item immediately
items = _get_playlist(silent=True) or items
list_mode = True
index_arg = None
else:
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
return 1

View File

@@ -1,10 +1,11 @@
"""Worker cmdlet: Display workers table in ResultTable format."""
from __future__ import annotations
from typing import Any, Dict, Sequence, List
import json
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Sequence, List
from cmdlets import register
from cmdlets._shared import Cmdlet, CmdletArg
@@ -12,6 +13,9 @@ import pipeline as ctx
from helper.logger import log
from config import get_local_storage_path
DEFAULT_LIMIT = 100
WORKER_STATUS_FILTERS = {"running", "completed", "error", "cancelled"}
HELP_FLAGS = {"-?", "/?", "--help", "-h", "help", "--cmdlet"}
CMDLET = Cmdlet(
name=".worker",
@@ -21,6 +25,8 @@ CMDLET = Cmdlet(
CmdletArg("status", description="Filter by status: running, completed, error (default: all)"),
CmdletArg("limit", type="integer", description="Limit results (default: 100)"),
CmdletArg("@N", description="Select worker by index (1-based) and display full logs"),
CmdletArg("-id", description="Show full logs for a specific worker"),
CmdletArg("-clear", type="flag", description="Remove completed workers from the database"),
],
details=[
"- Shows all background worker tasks and their output",
@@ -37,284 +43,285 @@ CMDLET = Cmdlet(
)
def _has_help_flag(args_list: Sequence[str]) -> bool:
return any(str(arg).lower() in HELP_FLAGS for arg in args_list)
@dataclass
class WorkerCommandOptions:
status: str | None = None
limit: int = DEFAULT_LIMIT
worker_id: str | None = None
clear: bool = False
@register([".worker", "worker", "workers"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Display workers table or show detailed logs for a specific worker."""
args_list = [str(arg) for arg in (args or [])]
selection_indices = ctx.get_last_selection()
selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0
"""Display workers table or show detailed logs for a specific worker."""
args_list = [str(arg) for arg in (args or [])]
selection_indices = ctx.get_last_selection()
selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0
# Parse arguments for list view
status_filter: str | None = None
limit = 100
clear_requested = False
worker_id_arg: str | None = None
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-limit", "--limit"} and i + 1 < len(args_list):
try:
limit = max(1, int(args_list[i + 1]))
except ValueError:
limit = 100
i += 2
elif low in {"-id", "--id"} and i + 1 < len(args_list):
worker_id_arg = args_list[i + 1]
i += 2
elif low in {"-clear", "--clear"}:
clear_requested = True
i += 1
elif low in {"running", "completed", "error", "cancelled"}:
status_filter = low
i += 1
elif not arg.startswith("-"):
status_filter = low
i += 1
else:
i += 1
if _has_help_flag(args_list):
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
return 0
try:
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
return 0
except Exception:
pass
options = _parse_worker_args(args_list)
library_root = get_local_storage_path(config or {})
if not library_root:
log("No library root configured", file=sys.stderr)
return 1
library_root = get_local_storage_path(config or {})
if not library_root:
log("No library root configured", file=sys.stderr)
return 1
try:
from helper.local_library import LocalLibraryDB
with LocalLibraryDB(library_root) as db:
if clear_requested:
count = db.clear_finished_workers()
log(f"Cleared {count} finished workers.")
return 0
if worker_id_arg:
worker = db.get_worker(worker_id_arg)
if worker:
events = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
else:
log(f"Worker not found: {worker_id_arg}", file=sys.stderr)
return 1
try:
from helper.local_library import LocalLibraryDB
if selection_requested:
return _render_worker_selection(db, result)
return _render_worker_list(db, status_filter, limit)
except Exception as exc:
log(f"Workers query failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return 1
with LocalLibraryDB(library_root) as db:
if options.clear:
count = db.clear_finished_workers()
log(f"Cleared {count} finished workers.")
return 0
if options.worker_id:
worker = db.get_worker(options.worker_id)
if worker:
events: List[Dict[str, Any]] = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
log(f"Worker not found: {options.worker_id}", file=sys.stderr)
return 1
if selection_requested:
return _render_worker_selection(db, result)
return _render_worker_list(db, options.status, options.limit)
except Exception as exc:
log(f"Workers query failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return 1
def _parse_worker_args(args_list: Sequence[str]) -> WorkerCommandOptions:
options = WorkerCommandOptions()
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-limit", "--limit"} and i + 1 < len(args_list):
options.limit = _normalize_limit(args_list[i + 1])
i += 2
elif low in {"-id", "--id"} and i + 1 < len(args_list):
options.worker_id = args_list[i + 1]
i += 2
elif low in {"-clear", "--clear"}:
options.clear = True
i += 1
elif low in {"-status", "--status"} and i + 1 < len(args_list):
options.status = args_list[i + 1].lower()
i += 2
elif low in WORKER_STATUS_FILTERS:
options.status = low
i += 1
elif not arg.startswith("-"):
options.status = low
i += 1
else:
i += 1
return options
def _normalize_limit(value: Any) -> int:
try:
return max(1, int(value))
except (TypeError, ValueError):
return DEFAULT_LIMIT
def _render_worker_list(db, status_filter: str | None, limit: int) -> int:
workers = db.get_all_workers(limit=limit)
if status_filter:
workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter]
workers = db.get_all_workers(limit=limit)
if status_filter:
workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter]
if not workers:
log("No workers found", file=sys.stderr)
return 0
if not workers:
log("No workers found", file=sys.stderr)
return 0
for worker in workers:
started = worker.get("started_at", "")
ended = worker.get("completed_at", worker.get("last_updated", ""))
date_str = _extract_date(started)
start_time = _format_event_timestamp(started)
end_time = _format_event_timestamp(ended)
item = {
"columns": [
("Status", worker.get("status", "")),
("Pipe", _summarize_pipe(worker.get("pipe"))),
("Date", date_str),
("Start Time", start_time),
("End Time", end_time),
],
"__worker_metadata": worker,
"_selection_args": ["-id", worker.get("worker_id")]
}
ctx.emit(item)
return 0
for worker in workers:
started = worker.get("started_at", "")
ended = worker.get("completed_at", worker.get("last_updated", ""))
date_str = _extract_date(started)
start_time = _format_event_timestamp(started)
end_time = _format_event_timestamp(ended)
item = {
"columns": [
("Status", worker.get("status", "")),
("Pipe", _summarize_pipe(worker.get("pipe"))),
("Date", date_str),
("Start Time", start_time),
("End Time", end_time),
],
"__worker_metadata": worker,
"_selection_args": ["-id", worker.get("worker_id")],
}
ctx.emit(item)
return 0
def _render_worker_selection(db, selected_items: Any) -> int:
if not isinstance(selected_items, list):
log("Selection payload missing", file=sys.stderr)
return 1
if not isinstance(selected_items, list):
log("Selection payload missing", file=sys.stderr)
return 1
emitted = False
for item in selected_items:
worker = _resolve_worker_record(db, item)
if not worker:
continue
events = []
try:
events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else []
except Exception:
events = []
_emit_worker_detail(worker, events)
emitted = True
if not emitted:
log("Selected rows no longer exist", file=sys.stderr)
return 1
return 0
emitted = False
for item in selected_items:
worker = _resolve_worker_record(db, item)
if not worker:
continue
events: List[Dict[str, Any]] = []
try:
events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else []
except Exception:
events = []
_emit_worker_detail(worker, events)
emitted = True
if not emitted:
log("Selected rows no longer exist", file=sys.stderr)
return 1
return 0
def _resolve_worker_record(db, payload: Any) -> Dict[str, Any] | None:
if not isinstance(payload, dict):
return None
worker_data = payload.get("__worker_metadata")
worker_id = None
if isinstance(worker_data, dict):
worker_id = worker_data.get("worker_id")
else:
worker_id = payload.get("worker_id")
worker_data = None
if worker_id:
fresh = db.get_worker(worker_id)
if fresh:
return fresh
return worker_data if isinstance(worker_data, dict) else None
if not isinstance(payload, dict):
return None
worker_data = payload.get("__worker_metadata")
worker_id = None
if isinstance(worker_data, dict):
worker_id = worker_data.get("worker_id")
else:
worker_id = payload.get("worker_id")
worker_data = None
if worker_id:
fresh = db.get_worker(worker_id)
if fresh:
return fresh
return worker_data if isinstance(worker_data, dict) else None
def _emit_worker_detail(worker: Dict[str, Any], events: List[Dict[str, Any]]) -> None:
# Parse stdout logs into rows
stdout_content = worker.get("stdout", "") or ""
# Try to parse lines if they follow the standard log format
# Format: YYYY-MM-DD HH:MM:SS - name - level - message
lines = stdout_content.splitlines()
for line in lines:
line = line.strip()
if not line:
continue
# Default values
timestamp = ""
level = "INFO"
message = line
# Try to parse standard format
try:
parts = line.split(" - ", 3)
if len(parts) >= 4:
# Full format
ts_str, _, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
elif len(parts) == 3:
# Missing name or level
ts_str, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
except Exception:
pass
item = {
"columns": [
("Time", timestamp),
("Level", level),
("Message", message)
]
}
ctx.emit(item)
# Also emit events if available and not redundant
# (For now, just focusing on stdout logs as requested)
stdout_content = worker.get("stdout", "") or ""
lines = stdout_content.splitlines()
for line in lines:
line = line.strip()
if not line:
continue
timestamp = ""
level = "INFO"
message = line
try:
parts = line.split(" - ", 3)
if len(parts) >= 4:
ts_str, _, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
elif len(parts) == 3:
ts_str, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
except Exception:
pass
item = {
"columns": [
("Time", timestamp),
("Level", level),
("Message", message),
]
}
ctx.emit(item)
# Events are already always derived from stdout for now.
def _summarize_pipe(pipe_value: Any, limit: int = 60) -> str:
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..."
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..."
def _format_event_timestamp(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%H:%M:%S")
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%H:%M:%S")
if not raw_timestamp:
return "--:--:--"
text = str(raw_timestamp)
if "T" in text:
time_part = text.split("T", 1)[1]
elif " " in text:
time_part = text.split(" ", 1)[1]
else:
time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part
if not raw_timestamp:
return "--:--:--"
text = str(raw_timestamp)
if "T" in text:
time_part = text.split("T", 1)[1]
elif " " in text:
time_part = text.split(" ", 1)[1]
else:
time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part
def _parse_to_local(timestamp_str: Any) -> datetime | None:
if not timestamp_str:
return None
text = str(timestamp_str).strip()
if not text:
return None
if not timestamp_str:
return None
text = str(timestamp_str).strip()
if not text:
return None
try:
# Check for T separator (Python isoformat - Local time)
if 'T' in text:
return datetime.fromisoformat(text)
# Check for space separator (SQLite CURRENT_TIMESTAMP - UTC)
# Format: YYYY-MM-DD HH:MM:SS
if ' ' in text:
# Assume UTC
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone() # Convert to local
except Exception:
pass
return None
try:
if "T" in text:
return datetime.fromisoformat(text)
if " " in text:
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone()
except Exception:
pass
return None
def _extract_date(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%m-%d-%y")
# Fallback
if not raw_timestamp:
return ""
text = str(raw_timestamp)
# Extract YYYY-MM-DD part
date_part = ""
if "T" in text:
date_part = text.split("T", 1)[0]
elif " " in text:
date_part = text.split(" ", 1)[0]
else:
date_part = text
# Convert YYYY-MM-DD to MM-DD-YY
try:
parts = date_part.split("-")
if len(parts) == 3:
year, month, day = parts
return f"{month}-{day}-{year[2:]}"
except Exception:
pass
return date_part
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%m-%d-%y")
if not raw_timestamp:
return ""
text = str(raw_timestamp)
date_part = ""
if "T" in text:
date_part = text.split("T", 1)[0]
elif " " in text:
date_part = text.split(" ", 1)[0]
else:
date_part = text
try:
parts = date_part.split("-")
if len(parts) == 3:
year, month, day = parts
return f"{month}-{day}-{year[2:]}"
except Exception:
pass
return date_part