update refactoring and add new features
This commit is contained in:
+165
-3
@@ -4,11 +4,27 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PluginCore.base import Provider
|
||||
from SYS.metadata import write_metadata, write_tags
|
||||
from PluginCore.base import Provider, SearchResult
|
||||
from SYS.metadata import _read_sidecar_metadata, read_tags_from_file, write_metadata, write_tags
|
||||
from SYS.utils import sanitize_filename, sha256_file, unique_path
|
||||
|
||||
|
||||
def _format_size_safe(size_bytes: Any) -> str:
|
||||
if size_bytes is None:
|
||||
return ""
|
||||
try:
|
||||
size = int(size_bytes)
|
||||
except (TypeError, ValueError):
|
||||
return str(size_bytes or "")
|
||||
if size < 1024:
|
||||
return f"{size} B"
|
||||
if size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f} KB"
|
||||
if size < 1024 * 1024 * 1024:
|
||||
return f"{size / (1024 * 1024):.1f} MB"
|
||||
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
def _copy_sidecars(source_path: Path, target_path: Path) -> None:
|
||||
possible_sidecars = [
|
||||
source_path.with_suffix(source_path.suffix + ".json"),
|
||||
@@ -80,7 +96,7 @@ class Local(Provider):
|
||||
PLUGIN_NAME = "local"
|
||||
PLUGIN_ALIASES = ("filesystem", "fs")
|
||||
MULTI_INSTANCE = True
|
||||
SUPPORTED_CMDLETS = frozenset({"add-file"})
|
||||
SUPPORTED_CMDLETS = frozenset({"add-file", "search-file"})
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
@@ -165,6 +181,152 @@ class Local(Provider):
|
||||
def validate(self) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _infer_media_kind(ext: str) -> str:
|
||||
e = str(ext or "").strip().lower().lstrip(".")
|
||||
if e in {"mp3", "flac", "wav", "ogg", "opus", "m4a", "aac", "wma", "aiff"}:
|
||||
return "audio"
|
||||
if e in {"mp4", "mkv", "avi", "mov", "webm", "wmv", "flv", "m4v"}:
|
||||
return "video"
|
||||
if e in {"pdf", "epub", "mobi", "azw3", "djvu", "cbr", "cbz", "chm"}:
|
||||
return "book"
|
||||
if e in {"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "iso"}:
|
||||
return "archive"
|
||||
if e in {"exe", "msi", "apk", "deb", "rpm", "appimage", "bin"}:
|
||||
return "software"
|
||||
if e in {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico"}:
|
||||
return "image"
|
||||
return "file"
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 50,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[SearchResult]:
|
||||
q = str(query or "").strip()
|
||||
match_all = not q or q == "*"
|
||||
query_tokens = [t.lower() for t in q.split()] if not match_all else []
|
||||
max_results = max(1, int(limit))
|
||||
|
||||
target_instance = None
|
||||
if filters:
|
||||
target_instance = str(filters.get("instance") or filters.get("store") or "").strip() or None
|
||||
|
||||
results: List[SearchResult] = []
|
||||
instances = self.plugin_instance_configs()
|
||||
|
||||
for inst_name, inst_cfg in instances.items():
|
||||
if target_instance and str(inst_name or "").strip().lower() != target_instance.lower():
|
||||
continue
|
||||
|
||||
settings = self._settings_from_config(inst_cfg, instance_name=inst_name)
|
||||
root_text = str(settings.get("path") or "").strip()
|
||||
if not root_text:
|
||||
continue
|
||||
|
||||
root = Path(root_text).expanduser()
|
||||
if not root.is_dir():
|
||||
continue
|
||||
|
||||
try:
|
||||
for entry in root.rglob("*"):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
if not entry.is_file():
|
||||
continue
|
||||
|
||||
file_name = entry.name
|
||||
file_stem = entry.stem
|
||||
|
||||
tag_path = entry.parent / (file_name + ".tag")
|
||||
meta_path = entry.parent / (file_name + ".metadata")
|
||||
|
||||
has_tag = tag_path.is_file()
|
||||
has_meta = meta_path.is_file()
|
||||
if not has_tag and not has_meta:
|
||||
continue
|
||||
|
||||
tags: List[str] = []
|
||||
if has_tag:
|
||||
try:
|
||||
tags = read_tags_from_file(tag_path)
|
||||
except Exception:
|
||||
tags = []
|
||||
|
||||
hash_value: Optional[str] = None
|
||||
urls: List[str] = []
|
||||
if has_meta:
|
||||
try:
|
||||
hash_value, _extra_tags, urls = _read_sidecar_metadata(meta_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not match_all and query_tokens:
|
||||
url_text = " ".join(urls) if urls else ""
|
||||
search_text = " ".join([file_name.lower(), file_stem.lower().replace("_", " "), *tags, url_text])
|
||||
if hash_value:
|
||||
search_text += " " + hash_value.lower()
|
||||
if not all(token in search_text for token in query_tokens):
|
||||
continue
|
||||
|
||||
try:
|
||||
size_bytes = int(entry.stat().st_size)
|
||||
except Exception:
|
||||
size_bytes = None
|
||||
|
||||
ext = entry.suffix.lstrip(".")
|
||||
media_kind = self._infer_media_kind(ext)
|
||||
|
||||
inst_label = str(inst_name or "").strip()
|
||||
if inst_label.lower() == "default":
|
||||
inst_label = ""
|
||||
|
||||
store_label = f"local:{inst_label}" if inst_label else "local"
|
||||
tag_text = ", ".join(tags[:8]) if tags else ""
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"store": store_label,
|
||||
"ext": ext,
|
||||
"size": size_bytes,
|
||||
}
|
||||
if inst_label:
|
||||
metadata["instance"] = inst_label
|
||||
if hash_value:
|
||||
metadata["hash"] = hash_value
|
||||
if urls:
|
||||
metadata["url"] = urls
|
||||
|
||||
sr = SearchResult(
|
||||
table="local",
|
||||
title=file_name,
|
||||
path=str(entry),
|
||||
detail=store_label,
|
||||
annotations=[store_label],
|
||||
media_kind=media_kind,
|
||||
size_bytes=size_bytes,
|
||||
tag=set(tags),
|
||||
columns=[
|
||||
("Title", file_name),
|
||||
("Tag", tag_text),
|
||||
("Store", store_label),
|
||||
("Size", _format_size_safe(size_bytes)),
|
||||
("Ext", ext),
|
||||
],
|
||||
full_metadata=metadata,
|
||||
)
|
||||
sr.ext = ext
|
||||
sr.size = size_bytes
|
||||
if hash_value:
|
||||
sr.hash = hash_value
|
||||
results.append(sr)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return results[:max_results]
|
||||
|
||||
@staticmethod
|
||||
def _folder_name_from_pipe(pipe_obj: Any) -> str:
|
||||
metadata = getattr(pipe_obj, "metadata", None)
|
||||
|
||||
+51
-35
@@ -1,5 +1,6 @@
|
||||
# pyright: reportUnusedFunction=false
|
||||
from typing import Any, Dict, Sequence, List, Optional
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@@ -21,6 +22,8 @@ from SYS.config import get_hydrus_access_key, get_hydrus_url, resolve_cookies_pa
|
||||
|
||||
_NOTES_PREFETCH_INFLIGHT: set[str] = set()
|
||||
_NOTES_PREFETCH_LOCK = threading.Lock()
|
||||
_PREFETCH_THREADS: set[threading.Thread] = set()
|
||||
_PREFETCH_THREADS_LOCK = threading.Lock()
|
||||
_PLAYLIST_STORE_CACHE: Optional[Dict[str, Any]] = None
|
||||
_PLAYLIST_STORE_MTIME_NS: Optional[int] = None
|
||||
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
||||
@@ -347,21 +350,20 @@ def _get_latest_mpv_run_marker(log_db_path: str) -> Optional[tuple[str, str]]:
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(log_db_path, timeout=5.0)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
(
|
||||
"SELECT timestamp, message FROM logs "
|
||||
"WHERE module = 'mpv' AND ("
|
||||
"message LIKE '[helper] version=% started ipc=%' "
|
||||
"OR message LIKE 'medeia lua loaded version=%'"
|
||||
") "
|
||||
"ORDER BY timestamp DESC LIMIT 1"
|
||||
with sqlite3.connect(log_db_path, timeout=5.0) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
(
|
||||
"SELECT timestamp, message FROM logs "
|
||||
"WHERE module = 'mpv' AND ("
|
||||
"message LIKE '[helper] version=% started ipc=%' "
|
||||
"OR message LIKE 'medeia lua loaded version=%'"
|
||||
") "
|
||||
"ORDER BY timestamp DESC LIMIT 1"
|
||||
)
|
||||
)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row and row[0]:
|
||||
return str(row[0]), str(row[1] or "")
|
||||
except Exception:
|
||||
@@ -381,26 +383,25 @@ def _get_mpv_logs_for_latest_run(
|
||||
try:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(log_db_path, timeout=5.0)
|
||||
cur = conn.cursor()
|
||||
query = "SELECT timestamp, level, module, message FROM logs WHERE module = 'mpv'"
|
||||
params: List[str] = []
|
||||
if marker_ts:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(marker_ts)
|
||||
else:
|
||||
cutoff = (datetime.utcnow() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(cutoff)
|
||||
if log_filter_text:
|
||||
query += " AND LOWER(message) LIKE ?"
|
||||
params.append(f"%{log_filter_text.lower()}%")
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(str(max(1, int(limit))))
|
||||
cur.execute(query, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
with sqlite3.connect(log_db_path, timeout=5.0) as conn:
|
||||
cur = conn.cursor()
|
||||
query = "SELECT timestamp, level, module, message FROM logs WHERE module = 'mpv'"
|
||||
params: List[str] = []
|
||||
if marker_ts:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(marker_ts)
|
||||
else:
|
||||
cutoff = (datetime.utcnow() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(cutoff)
|
||||
if log_filter_text:
|
||||
query += " AND LOWER(message) LIKE ?"
|
||||
params.append(f"%{log_filter_text.lower()}%")
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(str(max(1, int(limit))))
|
||||
cur.execute(query, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
rows.reverse()
|
||||
return marker_ts, marker_msg, rows
|
||||
except Exception:
|
||||
@@ -849,11 +850,26 @@ def _prefetch_notes_async(
|
||||
thread = threading.Thread(
|
||||
target=_worker,
|
||||
name=f"mpv-notes-prefetch-{file_hash[:8]}",
|
||||
daemon=True,
|
||||
)
|
||||
with _PREFETCH_THREADS_LOCK:
|
||||
_PREFETCH_THREADS.add(thread)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _shutdown_prefetch_threads() -> None:
|
||||
with _PREFETCH_THREADS_LOCK:
|
||||
threads = list(_PREFETCH_THREADS)
|
||||
_PREFETCH_THREADS.clear()
|
||||
for t in threads:
|
||||
try:
|
||||
t.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_shutdown_prefetch_threads)
|
||||
|
||||
|
||||
def _schedule_notes_prefetch(items: Sequence[Any], config: Optional[Dict[str, Any]]) -> None:
|
||||
limit = _get_lyric_prefetch_limit(config)
|
||||
if limit <= 0:
|
||||
|
||||
@@ -14,11 +14,6 @@ def _repo_root() -> Path:
|
||||
return package_dir.parent
|
||||
|
||||
|
||||
REPO_ROOT = _repo_root()
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
@@ -35,10 +30,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||
url = str(args[0] or "").strip()
|
||||
captured_stdout = io.StringIO()
|
||||
captured_stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||
from plugins.mpv.pipeline_helper import _run_op
|
||||
|
||||
payload = _run_op("ytdlp-formats", {"url": url})
|
||||
_root_str = str(_repo_root())
|
||||
_path_added = _root_str not in sys.path
|
||||
if _path_added:
|
||||
sys.path.insert(0, _root_str)
|
||||
try:
|
||||
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||
from plugins.mpv.pipeline_helper import _run_op
|
||||
|
||||
payload = _run_op("ytdlp-formats", {"url": url})
|
||||
finally:
|
||||
if _path_added:
|
||||
sys.path.remove(_root_str)
|
||||
|
||||
noisy_stdout = captured_stdout.getvalue().strip()
|
||||
noisy_stderr = captured_stderr.getvalue().strip()
|
||||
|
||||
@@ -7,6 +7,7 @@ This is the central hub for all Python-mpv IPC communication. The Lua script
|
||||
should use the Python CLI, which uses this module to manage mpv connections.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
@@ -901,6 +902,7 @@ class MPVIPCClient:
|
||||
self.is_windows = platform.system() == "Windows"
|
||||
self.silent = bool(silent)
|
||||
self._recv_buffer: bytes = b""
|
||||
atexit.register(self.disconnect)
|
||||
|
||||
def _write_payload(self, payload: str) -> None:
|
||||
if not self.sock:
|
||||
@@ -1229,10 +1231,6 @@ class MPVIPCClient:
|
||||
self.sock = None
|
||||
self._recv_buffer = b""
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Cleanup on object destruction."""
|
||||
self.disconnect()
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
self.connect()
|
||||
|
||||
@@ -61,17 +61,22 @@ def _runtime_config_root() -> Path:
|
||||
|
||||
|
||||
# Make repo-local packages importable even when mpv starts us from another cwd.
|
||||
_ROOT = str(_repo_root())
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
from plugins.mpv.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs, _windows_list_mpv_pids # noqa: E402
|
||||
from SYS.config import load_config, reload_config # noqa: E402
|
||||
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
|
||||
from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
|
||||
from SYS.utils import format_bytes # noqa: E402
|
||||
from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
|
||||
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
|
||||
_root_str = str(_repo_root())
|
||||
_path_added = _root_str not in sys.path
|
||||
if _path_added:
|
||||
sys.path.insert(0, _root_str)
|
||||
try:
|
||||
from plugins.mpv.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs, _windows_list_mpv_pids # noqa: E402
|
||||
from SYS.config import load_config, reload_config # noqa: E402
|
||||
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
|
||||
from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
|
||||
from SYS.utils import format_bytes # noqa: E402
|
||||
from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
|
||||
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
|
||||
finally:
|
||||
if _path_added:
|
||||
sys.path.remove(_root_str)
|
||||
del _root_str, _path_added
|
||||
|
||||
REQUEST_PROP = "user-data/medeia-pipeline-request"
|
||||
RESPONSE_PROP = "user-data/medeia-pipeline-response"
|
||||
|
||||
@@ -7,7 +7,10 @@ import `plugins.playwright` even when Playwright itself is not installed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .runtime import USER_AGENT
|
||||
|
||||
__all__ = [
|
||||
"USER_AGENT",
|
||||
"PlaywrightTimeoutError",
|
||||
"PlaywrightTool",
|
||||
"PlaywrightDefaults",
|
||||
|
||||
@@ -65,15 +65,18 @@ def _find_filename_from_cd(cd: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PlaywrightDefaults:
|
||||
browser: str = "chromium" # chromium|firefox|webkit
|
||||
headless: bool = True
|
||||
user_agent: str = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
user_agent: str = USER_AGENT
|
||||
viewport_width: int = 1920
|
||||
viewport_height: int = 1080
|
||||
navigation_timeout_ms: int = 90_000
|
||||
|
||||
@@ -243,9 +243,11 @@ class _LineFilterStream(io.TextIOBase):
|
||||
def _suppress_aioslsk_noise() -> Any:
|
||||
"""Temporarily suppress known aioslsk noise printed to stdout/stderr.
|
||||
|
||||
Opt out by setting DOWNLOW_SOULSEEK_VERBOSE=1.
|
||||
Opt out by setting DOWNLOAD_SOULSEEK_VERBOSE=1.
|
||||
|
||||
Note: The old env var name DOWNLOW_SOULSEEK_VERBOSE (typo) is deprecated.
|
||||
"""
|
||||
if os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
|
||||
if os.environ.get("DOWNLOAD_SOULSEEK_VERBOSE") or os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
|
||||
yield
|
||||
return
|
||||
|
||||
@@ -404,7 +406,7 @@ class Soulseek(Provider):
|
||||
)
|
||||
|
||||
except RuntimeError:
|
||||
# If we're already inside an event loop (e.g., TUI), fall back to a
|
||||
# If we're already inside an event loop, fall back to a
|
||||
# dedicated loop in this thread.
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
@@ -568,6 +570,21 @@ class Soulseek(Provider):
|
||||
timeout=9.0,
|
||||
limit=limit)
|
||||
)
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
flat_results, search_summary = loop.run_until_complete(
|
||||
self.perform_search(query, timeout=9.0, limit=limit)
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
except Exception:
|
||||
pass
|
||||
loop.close()
|
||||
|
||||
try:
|
||||
if not flat_results:
|
||||
return []
|
||||
|
||||
|
||||
@@ -319,7 +319,7 @@ class Telegram(Provider):
|
||||
def _run_async_blocking(self, coro):
|
||||
"""Run an awaitable to completion using a fresh event loop.
|
||||
|
||||
If an event loop is already running in this thread (common in REPL/TUI),
|
||||
If an event loop is already running in this thread (common in REPL),
|
||||
runs the coroutine in a worker thread with its own loop.
|
||||
"""
|
||||
result: Dict[str,
|
||||
@@ -358,6 +358,13 @@ class Telegram(Provider):
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_default_executor())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
@@ -559,6 +566,10 @@ class Telegram(Provider):
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(_auth_async())
|
||||
finally:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_default_executor())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
|
||||
@@ -510,6 +510,14 @@ class ytdlp(TablePluginMixin, Provider):
|
||||
SEARCH_QUERY_KEYS = ("search", "q")
|
||||
SUPPORTED_CMDLETS = frozenset({"download-file", "search-file"})
|
||||
|
||||
QUERY_ARG_CHOICES = {
|
||||
"format": ["audio", "1080", "720", "480", "360", "best", "worst", "bestvideo+bestaudio"],
|
||||
"clip": ["0:00-1:00"],
|
||||
"audio": ["true", "false", "1", "0"],
|
||||
"item": ["1"],
|
||||
}
|
||||
INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES
|
||||
|
||||
@staticmethod
|
||||
def config_schema() -> List[Dict[str, Any]]:
|
||||
return _ytdlp_config_schema()
|
||||
|
||||
Reference in New Issue
Block a user