update and cleanup repo

This commit is contained in:
2026-05-26 15:32:01 -07:00
parent 5041d9fbb9
commit 0db899d0c3
72 changed files with 788 additions and 1884 deletions
+24 -58
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
from urllib.parse import urlparse
from API.HTTP import HTTPClient, _download_direct_file
from API.HTTP import HTTPClient, download_direct_file
from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file
from PluginCore.base import Provider, SearchResult
from SYS.plugin_helpers import TablePluginMixin
@@ -197,47 +197,14 @@ def refresh_alldebrid_hoster_cache(*, force: bool = False) -> None:
def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
"""Read AllDebrid API key from config.
Preferred formats:
- config.conf provider block:
[provider=alldebrid]
api_key=...
-> config["provider"]["alldebrid"]["api_key"]
- plugin-style debrid block:
config["plugin"]["debrid"]["all-debrid"]["api_key"]
Falls back to some legacy keys if present.
"""
# 1) provider block: [provider=alldebrid]
provider = config.get("provider")
if isinstance(provider, dict):
entry = provider.get("alldebrid")
if isinstance(entry, dict):
for k in ("api_key", "apikey", "API_KEY", "APIKEY"):
val = entry.get(k)
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(entry, str) and entry.strip():
return entry.strip()
# 2) plugin debrid block
"""Read the canonical AllDebrid API key from config."""
try:
from SYS.config import get_debrid_api_key
key = get_debrid_api_key(config, service="All-debrid")
return key.strip() if key else None
except Exception:
pass
# Legacy fallback (kept permissive so older configs still work)
for legacy_key in ("alldebrid_api_key", "AllDebrid", "all_debrid_api_key"):
val = config.get(legacy_key)
if isinstance(val, str) and val.strip():
return val.strip()
return None
return None
def _consume_bencoded_value(data: bytes, pos: int) -> int:
@@ -399,8 +366,8 @@ def _build_queued_magnet_item(
metadata: Dict[str, Any] = {
"magnet_id": magnet_id,
"provider": "alldebrid",
"provider_view": "files",
"plugin": "alldebrid",
"plugin_view": "files",
"magnet_spec": magnet_spec,
"source_url": magnet_spec,
"status": status_label,
@@ -412,7 +379,6 @@ def _build_queued_magnet_item(
return {
"table": "alldebrid",
"provider": "alldebrid",
"plugin": "alldebrid",
"path": f"{_ALD_MAGNET_PREFIX}{magnet_id}",
"title": title,
@@ -539,7 +505,7 @@ def download_magnet(
output_dir = target_path
try:
result_obj = _download_direct_file(
result_obj = download_direct_file(
file_url,
output_dir,
quiet=quiet_mode,
@@ -800,8 +766,8 @@ class AllDebrid(TablePluginMixin, Provider):
"title": f"magnet-{magnet_id}",
"metadata": {
"magnet_id": magnet_id,
"provider": "alldebrid",
"provider_view": "files",
"plugin": "alldebrid",
"plugin_view": "files",
},
}
@@ -952,7 +918,7 @@ class AllDebrid(TablePluginMixin, Provider):
pipe_progress = None
try:
dl_res = _download_direct_file(
dl_res = download_direct_file(
unlocked_url,
Path(output_dir),
quiet=quiet,
@@ -965,7 +931,7 @@ class AllDebrid(TablePluginMixin, Provider):
downloaded_path = Path(str(downloaded_path))
except DownloadError as exc:
log(
f"[alldebrid] _download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr
f"[alldebrid] download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr
)
return None
@@ -1360,7 +1326,7 @@ class AllDebrid(TablePluginMixin, Provider):
suggested_name = rel_path_obj.name or file_name or f"file-{file_idx}"
try:
result_obj = _download_direct_file(
result_obj = download_direct_file(
file_url,
target_path,
quiet=quiet_mode,
@@ -1482,8 +1448,8 @@ class AllDebrid(TablePluginMixin, Provider):
full_metadata={
"magnet": magnet_status,
"magnet_id": magnet_id,
"provider": "alldebrid",
"provider_view": "files",
"plugin": "alldebrid",
"plugin_view": "files",
"magnet_name": magnet_name,
},
)
@@ -1535,8 +1501,8 @@ class AllDebrid(TablePluginMixin, Provider):
"magnet_name": magnet_name,
"relpath": relpath,
"file": file_node,
"provider": "alldebrid",
"provider_view": "files",
"plugin": "alldebrid",
"plugin_view": "files",
# Selection metadata for table system
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
"_selection_action": ["download-file", "-plugin", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
@@ -1649,8 +1615,8 @@ class AllDebrid(TablePluginMixin, Provider):
full_metadata={
"magnet": magnet,
"magnet_id": magnet_id,
"provider": "alldebrid",
"provider_view": "folders",
"plugin": "alldebrid",
"plugin_view": "folders",
"magnet_name": magnet_name,
# Selection metadata: allow @N expansion to drive downloads directly
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
@@ -1752,7 +1718,7 @@ class AllDebrid(TablePluginMixin, Provider):
table = Table(f"AllDebrid Files: {title}")._perseverance(True)
table.set_table("alldebrid")
try:
table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id})
table.set_table_metadata({"plugin": "alldebrid", "view": "files", "magnet_id": magnet_id})
except Exception:
pass
table.set_source_command("download-file", ["-plugin", "alldebrid"])
@@ -1844,7 +1810,7 @@ class AllDebrid(TablePluginMixin, Provider):
"Magnet": magnet_name or None,
"Magnet ID": magnet_id,
"Relative Path": relpath or None,
"View": str(meta.get("provider_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None,
"View": str(meta.get("plugin_view") or meta.get("view") or (table_metadata or {}).get("view") or "").strip() or None,
"Direct Url": direct_url or None,
"Selection Url": selection_url or None,
},
@@ -1942,7 +1908,7 @@ try:
if table_name:
metadata.setdefault("table", table_name)
metadata.setdefault("source", table_name)
metadata.setdefault("provider", table_name)
metadata.setdefault("plugin", table_name)
ext = payload.get("ext")
if not ext and isinstance(path_val, str):
@@ -2003,8 +1969,8 @@ try:
cols.append(metadata_column("ready", "Ready"))
if _has_metadata(rows, "relpath"):
cols.append(metadata_column("relpath", "File Path"))
if _has_metadata(rows, "provider_view"):
cols.append(metadata_column("provider_view", "View"))
if _has_metadata(rows, "plugin_view"):
cols.append(metadata_column("plugin_view", "View"))
if _has_metadata(rows, "size"):
cols.append(metadata_column("size", "Size"))
return cols
@@ -2016,7 +1982,7 @@ try:
Selection precedence:
1. Explicit _selection_action (full command args)
2. Explicit _selection_args (URL-specific args)
3. Magic routing based on provider_view (files vs folders)
3. Magic routing based on plugin_view (files vs folders)
4. Magnet ID routing for folder-type rows (via alldebrid:magnet:<id>)
5. Direct URL for file rows
@@ -2035,7 +2001,7 @@ try:
return [str(x) for x in args if x is not None]
# Magic routing by view type
view = metadata.get("provider_view") or metadata.get("view") or ""
view = metadata.get("plugin_view") or metadata.get("view") or ""
if view == "files":
# File rows: pass direct URL for immediate download
if row.path:
+4 -18
View File
@@ -1027,7 +1027,7 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
unlock-link # Uses URL from pipeline result
Requires:
- AllDebrid API key in config under Debrid.All-debrid
- AllDebrid API key in config under plugin.alldebrid.api_key
Args:
result: Pipeline result object
@@ -1054,28 +1054,14 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
return None
def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]:
# Current config format
try:
provider_cfg = cfg.get("provider") if isinstance(cfg, dict) else None
ad_cfg = provider_cfg.get("alldebrid"
) if isinstance(provider_cfg,
dict) else None
api_key = ad_cfg.get("api_key") if isinstance(ad_cfg, dict) else None
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
except Exception:
pass
from SYS.config import get_debrid_api_key
# Legacy config format fallback (best-effort)
try:
debrid_cfg = cfg.get("Debrid") if isinstance(cfg, dict) else None
api_key = None
if isinstance(debrid_cfg, dict):
api_key = debrid_cfg.get("All-debrid") or debrid_cfg.get("AllDebrid")
api_key = get_debrid_api_key(cfg, service="All-debrid")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
except Exception:
pass
return None
return None
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
from PluginCore.base import Provider, SearchResult
from SYS.logger import log, debug, debug_panel
from tool.playwright import PlaywrightTool
from plugins.playwright import PlaywrightTool
class Bandcamp(Provider):
+3 -3
View File
@@ -11,10 +11,10 @@ from SYS.logger import log
def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict):
return {}
provider = config.get("provider")
if not isinstance(provider, dict):
plugin_cfg = config.get("plugin")
if not isinstance(plugin_cfg, dict):
return {}
entry = provider.get("file.io")
entry = plugin_cfg.get("file.io")
if isinstance(entry, dict):
return entry
return {}
+27
View File
@@ -0,0 +1,27 @@
"""FlorenceVision support module under the plugin namespace."""
from __future__ import annotations
__all__ = [
"FlorenceVisionTool",
"FlorenceVisionDefaults",
"config_schema",
]
_MODULE_ATTRS = {
"FlorenceVisionTool": ".runtime",
"FlorenceVisionDefaults": ".runtime",
"config_schema": ".runtime",
}
def __getattr__(name: str) -> object:
submod = _MODULE_ATTRS.get(name)
if submod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(submod, package=__name__)
obj = getattr(mod, name)
globals()[name] = obj
return obj
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -359,7 +359,7 @@ class FTP(Provider):
table.set_table("ftp")
try:
table.set_table_metadata({
"provider": "ftp",
"plugin": "ftp",
"instance": instance_name or None,
"host": settings.get("host"),
"path": target_path,
@@ -792,7 +792,7 @@ class FTP(Provider):
parent = posixpath.dirname(ftp_path.rstrip("/")) or "/"
instance_name = str(settings.get("instance") or "").strip()
metadata = {
"provider": "ftp",
"plugin": "ftp",
"instance": instance_name or None,
"host": settings.get("host"),
"ftp_path": ftp_path,
+1 -1
View File
@@ -153,7 +153,7 @@ class HelloProvider(Provider):
table = Table(f"Hello Details: {title}")._perseverance(True)
table.set_table("hello")
try:
table.set_table_metadata({"provider": "hello", "view": "details", "example_index": idx})
table.set_table_metadata({"plugin": "hello", "view": "details", "example_index": idx})
except Exception:
pass
+7 -7
View File
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from plugins.tidal.api import (
Tidal as TidalApiClient,
Tidal,
build_track_tags,
coerce_duration_seconds,
extract_artists,
@@ -97,7 +97,7 @@ class HIFI(Provider):
self.api_timeout = float(self.config.get("timeout", 10.0))
except Exception:
self.api_timeout = 10.0
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
normalized, parsed = parse_inline_query_arguments(query)
@@ -744,7 +744,7 @@ class HIFI(Provider):
try:
table.set_table_metadata(
{
"provider": "hifi",
"plugin": "hifi",
"view": "track",
"album_id": album_id,
"album_title": album_title,
@@ -1376,7 +1376,7 @@ class HIFI(Provider):
return False, None
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]:
base = base_url.rstrip("/")
for client in self.api_clients:
if getattr(client, "base_url", "").rstrip("/") == base:
@@ -1935,7 +1935,7 @@ class HIFI(Provider):
table = Table(f"HIFI Albums: {artist_name}")._perseverance(False)
table.set_table("hifi.album")
try:
table.set_table_metadata({"provider": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
table.set_table_metadata({"plugin": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
except Exception:
pass
@@ -1997,7 +1997,7 @@ class HIFI(Provider):
try:
table.set_table_metadata(
{
"provider": "hifi",
"plugin": "hifi",
"view": "track",
"album_id": album_id,
"album_title": album_title,
@@ -2061,7 +2061,7 @@ class HIFI(Provider):
table = Table("HIFI Track")._perseverance(True)
table.set_table("hifi.track")
try:
table.set_table_metadata({"provider": "hifi", "view": "track", "resolved_manifest": True})
table.set_table_metadata({"plugin": "hifi", "view": "track", "resolved_manifest": True})
except Exception:
pass
results_payload: List[Dict[str, Any]] = []
+9 -15
View File
@@ -10,11 +10,11 @@ from typing import Any, Dict, List, Optional
from urllib.parse import quote, unquote, urlparse
from API.HTTP import _download_direct_file
from API.HTTP import download_direct_file
from PluginCore.base import Provider, SearchResult
from SYS.utils import sanitize_filename, unique_path
from SYS.logger import log
from SYS.config import get_provider_block
from SYS.config import get_plugin_block
# Helper for download-file: render selectable formats for a details URL.
def maybe_show_formats_table(
@@ -177,26 +177,20 @@ def _ia() -> Any:
def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict):
return {}
provider = config.get("provider")
if not isinstance(provider, dict):
return {}
entry = provider.get("internetarchive")
if isinstance(entry, dict):
return entry
return {}
return get_plugin_block(config, "internetarchive")
def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]:
"""Resolve Archive.org credentials.
Preference order:
1) provider.internetarchive (email/username + password)
2) provider.openlibrary (email + password)
1) plugin.internetarchive (email/username + password)
2) plugin.openlibrary (email + password)
"""
if not isinstance(config, dict):
return None, None
ia_block = get_provider_block(config, "internetarchive")
ia_block = get_plugin_block(config, "internetarchive")
if isinstance(ia_block, dict):
email = (
ia_block.get("email")
@@ -209,7 +203,7 @@ def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]
if email_text and password_text:
return email_text, password_text
ol_block = get_provider_block(config, "openlibrary")
ol_block = get_plugin_block(config, "openlibrary")
if isinstance(ol_block, dict):
email = ol_block.get("email")
password = ol_block.get("password")
@@ -744,7 +738,7 @@ class InternetArchive(Provider):
if tags:
normalized["tags"] = tags
normalized["media_kind"] = "book"
normalized["provider_action"] = "borrow"
normalized["plugin_action"] = "borrow"
return normalized
def validate(self) -> bool:
@@ -993,7 +987,7 @@ class InternetArchive(Provider):
pipeline_progress = None
try:
direct_result = _download_direct_file(
direct_result = download_direct_file(
raw_path,
output_dir,
quiet=quiet_mode,
+1 -1
View File
@@ -279,7 +279,7 @@ class Local(Provider):
return {
"hash": hash_value or "unknown",
"store": "local",
"provider": self.name,
"plugin": self.name,
"path": str(target_path),
"tag": tags,
"title": title or target_path.name,
+13 -17
View File
@@ -327,10 +327,10 @@ class Matrix(TablePluginMixin, Provider):
self._init_reason: Optional[str] = None
matrix_conf = (
self.config.get("provider",
{}).get("matrix",
{}) if isinstance(self.config,
dict) else {}
self.config.get("plugin",
{}).get("matrix",
{}) if isinstance(self.config,
dict) else {}
)
homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token")
@@ -362,16 +362,16 @@ class Matrix(TablePluginMixin, Provider):
return False
if self._init_ok is False:
return False
matrix_conf = self.config.get("provider",
{}).get("matrix",
{})
matrix_conf = self.config.get("plugin",
{}).get("matrix",
{})
return bool(
matrix_conf.get("homeserver")
and matrix_conf.get("access_token")
)
def status_summary(self) -> Dict[str, Any]:
matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
matrix_conf = self.config.get("plugin", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
homeserver = str(matrix_conf.get("homeserver") or "").strip()
room_id = str(matrix_conf.get("room_id") or "").strip()
detail = homeserver
@@ -439,7 +439,7 @@ class Matrix(TablePluginMixin, Provider):
full_metadata={
"room_id": room_id,
"room_name": room_name,
"provider": "matrix",
"plugin": "matrix",
# Selection metadata for table system and @N expansion
"_selection_args": ["-room-id", room_id],
},
@@ -450,9 +450,9 @@ class Matrix(TablePluginMixin, Provider):
def _get_homeserver_and_token(self) -> Tuple[str, str]:
matrix_conf = self.config.get("provider",
{}).get("matrix",
{})
matrix_conf = self.config.get("plugin",
{}).get("matrix",
{})
homeserver = matrix_conf.get("homeserver")
access_token = matrix_conf.get("access_token")
if not homeserver:
@@ -681,7 +681,7 @@ class Matrix(TablePluginMixin, Provider):
)
def upload(self, file_path: str, **kwargs: Any) -> str:
matrix_conf = self.config.get("provider",
matrix_conf = self.config.get("plugin",
{}).get("matrix",
{})
room_id = matrix_conf.get("room_id")
@@ -877,7 +877,3 @@ try:
except Exception:
# best-effort registration
pass
# Backward-compatible alias: tests and callers may import `plugins.matrix.cmdnat`.
from . import commands as cmdnat # noqa: E402
+35 -42
View File
@@ -58,8 +58,30 @@ def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]:
return extract_arg_value(args, flags={"-set-value"})
def _get_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(config, dict):
return {}
plugins = config.get("plugin")
if not isinstance(plugins, dict):
return {}
matrix_cfg = plugins.get("matrix")
return matrix_cfg if isinstance(matrix_cfg, dict) else {}
def _ensure_matrix_config_block(config: Dict[str, Any]) -> Dict[str, Any]:
plugins = config.setdefault("plugin", {})
if not isinstance(plugins, dict):
plugins = {}
config["plugin"] = plugins
matrix_cfg = plugins.setdefault("matrix", {})
if not isinstance(matrix_cfg, dict):
matrix_cfg = {}
plugins["matrix"] = matrix_cfg
return matrix_cfg
def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
"""Update the Matrix provider block in the shared config.
"""Update the Matrix plugin block in the shared config.
This method writes to the unified config store so changes persist between
sessions.
@@ -71,29 +93,13 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
value_str = str(value)
current_cfg = load_config() or {}
providers = current_cfg.setdefault("provider", {})
if not isinstance(providers, dict):
providers = {}
current_cfg["provider"] = providers
matrix_cfg = providers.setdefault("matrix", {})
if not isinstance(matrix_cfg, dict):
matrix_cfg = {}
providers["matrix"] = matrix_cfg
matrix_cfg = _ensure_matrix_config_block(current_cfg)
matrix_cfg[key] = value_str
save_config(current_cfg)
# Keep the supplied config dict in sync for the running CLI
target_providers = config.setdefault("provider", {})
if not isinstance(target_providers, dict):
target_providers = {}
config["provider"] = target_providers
target_matrix = target_providers.setdefault("matrix", {})
if not isinstance(target_matrix, dict):
target_matrix = {}
target_providers["matrix"] = target_matrix
target_matrix = _ensure_matrix_config_block(config)
target_matrix[key] = value_str
return True
except Exception as exc:
@@ -103,13 +109,8 @@ def _update_matrix_config(config: Dict[str, Any], key: str, value: Any) -> bool:
def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
try:
if not isinstance(config, dict):
return []
providers = config.get("provider")
if not isinstance(providers, dict):
return []
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
matrix_conf = _get_matrix_config_block(config)
if not matrix_conf:
return []
raw = None
# Support a few common spellings; `room` is the documented key.
@@ -138,16 +139,11 @@ def _parse_config_room_filter_ids(config: Dict[str, Any]) -> List[str]:
def _get_matrix_size_limit_bytes(config: Dict[str, Any]) -> Optional[int]:
"""Return max allowed per-file size in bytes for Matrix uploads.
Config: [provider=Matrix] size_limit=50 # MB
Config: [plugin=matrix] size_limit=50 # MB
"""
try:
if not isinstance(config, dict):
return None
providers = config.get("provider")
if not isinstance(providers, dict):
return None
matrix_conf = providers.get("matrix")
if not isinstance(matrix_conf, dict):
matrix_conf = _get_matrix_config_block(config)
if not matrix_conf:
return None
raw = None
@@ -236,7 +232,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
conf_ids = _parse_config_room_filter_ids(config)
if conf_ids:
# Attempt to fetch names for the configured IDs
block = config.get("provider", {}).get("matrix", {})
block = _get_matrix_config_block(config)
if block and block.get("homeserver") and block.get("access_token"):
try:
m = _get_matrix_provider(config)
@@ -252,7 +248,7 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
pass
# Last resort: attempt to ask the server for matching rooms (if possible)
block = config.get("provider", {}).get("matrix", {})
block = _get_matrix_config_block(config)
if block and block.get("homeserver") and block.get("access_token"):
try:
m = _get_matrix_provider(config)
@@ -631,7 +627,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
url = _resolve_plugin_url(url, config)
try:
from API.HTTP import _download_direct_file
from API.HTTP import download_direct_file
base_tmp = None
if isinstance(config, dict):
@@ -642,7 +638,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
)
output_dir = output_dir / "matrix"
output_dir.mkdir(parents=True, exist_ok=True)
result = _download_direct_file(url, output_dir, quiet=True)
result = download_direct_file(url, output_dir, quiet=True)
if (result and hasattr(result,
"path") and isinstance(result.path,
Path) and result.path.exists()):
@@ -691,10 +687,7 @@ def _show_settings_table(config: Dict[str, Any]) -> int:
matrix_conf = {}
try:
if isinstance(config, dict):
providers = config.get("provider")
if isinstance(providers, dict):
matrix_conf = providers.get("matrix") or {}
matrix_conf = _get_matrix_config_block(config)
except Exception:
pass
+11 -11
View File
@@ -252,7 +252,7 @@ class ITunesMetadataPlugin(MetadataPlugin):
"album": r.get("collectionName"),
"year": str(r.get("releaseDate",
""))[:4],
"provider": self.name,
"plugin": self.name,
"raw": r,
}
items.append(item)
@@ -338,7 +338,7 @@ class OpenLibraryMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "",
"album": publisher,
"year": str(doc.get("first_publish_year") or ""),
"provider": self.name,
"plugin": self.name,
"authors": authors,
"publisher": publisher,
"identifiers": {
@@ -460,7 +460,7 @@ class GoogleBooksMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "",
"album": publisher,
"year": year,
"provider": self.name,
"plugin": self.name,
"authors": authors,
"publisher": publisher,
"identifiers": identifiers,
@@ -643,7 +643,7 @@ class ISBNsearchMetadataPlugin(MetadataPlugin):
"artist": ", ".join(authors) if authors else "",
"album": publisher or "",
"year": year or "",
"provider": self.name,
"plugin": self.name,
"authors": authors,
"publisher": publisher or "",
"language": language or "",
@@ -787,7 +787,7 @@ class MusicBrainzMetadataPlugin(MetadataPlugin):
"artist": artist,
"album": album,
"year": year,
"provider": self.name,
"plugin": self.name,
"mbid": mbid,
"raw": rec,
}
@@ -871,7 +871,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
"artist": "",
"album": "",
"year": str(year or ""),
"provider": self.name,
"plugin": self.name,
"imdb_id": imdb_id,
"raw": data,
}
@@ -908,7 +908,7 @@ class ImdbMetadataPlugin(MetadataPlugin):
"artist": "",
"album": kind,
"year": year,
"provider": self.name,
"plugin": self.name,
"imdb_id": imdb_id,
"kind": kind,
"rating": rating,
@@ -1032,7 +1032,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
"artist": str(artist or ""),
"album": str(album or ""),
"year": str(year or ""),
"provider": self.name,
"plugin": self.name,
"url": url,
"raw": info,
}
@@ -1214,7 +1214,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
return None
try:
from tool.ytdlp import is_url_supported_by_ytdlp
from plugins.ytdlp.tooling import is_url_supported_by_ytdlp
for text in candidates:
try:
@@ -1322,7 +1322,7 @@ class YtdlpMetadataPlugin(MetadataPlugin):
"artist": str(info.get("artist") or info.get("uploader") or info.get("channel") or ""),
"album": str(info.get("album") or info.get("playlist_title") or ""),
"year": str((str(info.get("release_date") or "") or str(info.get("upload_date") or ""))[:4]),
"provider": self.name,
"plugin": self.name,
"url": str(url or "").strip(),
"raw": info,
}
@@ -1927,7 +1927,7 @@ class TidalMetadataPlugin(MetadataPlugin):
"year": year,
"lyrics": lyrics,
"tags": tags,
"provider": self.name,
"plugin": self.name,
"path": getattr(result, "path", ""),
"track_id": track_id,
"full_metadata": metadata,
+4 -4
View File
@@ -2878,7 +2878,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
screenshot_url = ''
end
local cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store)
.. ' -path ' .. quote_pipeline_arg(out_path)
.. ' ' .. quote_pipeline_arg(out_path)
if screenshot_url ~= '' then
cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url)
end
@@ -6367,7 +6367,7 @@ local function _start_trim_with_range(range)
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) ..
' -path ' .. quote_pipeline_arg(output_path) ..
' ' .. quote_pipeline_arg(output_path) ..
' | add-relationship -store "' .. selected_store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
else
@@ -6375,7 +6375,7 @@ local function _start_trim_with_range(range)
'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
' | file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(store_hash.store) ..
' -path ' .. quote_pipeline_arg(output_path) ..
' ' .. quote_pipeline_arg(output_path) ..
' | add-relationship -store "' .. store_hash.store .. '"' ..
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
end
@@ -6386,7 +6386,7 @@ local function _start_trim_with_range(range)
_lua_log('trim: building file -add command to selected_store=' .. selected_store)
-- Don't add title if empty - the file path will be used as title by default
pipeline_cmd = 'file -add -plugin hydrusnetwork -instance ' .. quote_pipeline_arg(selected_store) ..
' -path ' .. quote_pipeline_arg(output_path)
' ' .. quote_pipeline_arg(output_path)
_lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
else
mp.osd_message('Trim complete: ' .. output_path, 5)
+1 -1
View File
@@ -71,7 +71,7 @@ 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 tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
REQUEST_PROP = "user-data/medeia-pipeline-request"
RESPONSE_PROP = "user-data/medeia-pipeline-response"
+5 -5
View File
@@ -674,7 +674,7 @@ class OpenLibrary(Provider):
if not isinstance(config, dict):
return _DEFAULT_PREFERRED_LANGUAGE
entry = config.get("provider", {}).get("openlibrary", {})
entry = config.get("plugin", {}).get("openlibrary", {})
if not isinstance(entry, dict):
return _DEFAULT_PREFERRED_LANGUAGE
@@ -1118,7 +1118,7 @@ class OpenLibrary(Provider):
table = Table(f"OpenLibrary Editions: {title}")._perseverance(True)
table.set_table("openlibrary.edition")
try:
table.set_table_metadata({"provider": "openlibrary", "view": "borrowable_editions"})
table.set_table_metadata({"plugin": "openlibrary", "view": "borrowable_editions"})
except Exception:
pass
table.set_source_command("search-file", ["-plugin", "openlibrary"])
@@ -1274,7 +1274,7 @@ class OpenLibrary(Provider):
if not isinstance(config, dict):
return None, None
entry = config.get("provider", {}).get("openlibrary", {})
entry = config.get("plugin", {}).get("openlibrary", {})
if isinstance(entry, dict):
email = entry.get("email")
password = entry.get("password")
@@ -1287,11 +1287,11 @@ class OpenLibrary(Provider):
@classmethod
def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int:
"""Resolve Archive.org book-reader scale from provider config."""
"""Resolve Archive.org book-reader scale from plugin config."""
if not isinstance(config, dict):
return _DEFAULT_ARCHIVE_SCALE
entry = config.get("provider", {}).get("openlibrary", {})
entry = config.get("plugin", {}).get("openlibrary", {})
if not isinstance(entry, dict):
return _DEFAULT_ARCHIVE_SCALE
+36
View File
@@ -0,0 +1,36 @@
"""Playwright support module under the plugin namespace.
This package provides shared browser automation defaults/helpers for cmdlets and
plugins. It is intentionally lightweight at import time so plugin discovery can
import `plugins.playwright` even when Playwright itself is not installed.
"""
from __future__ import annotations
__all__ = [
"PlaywrightTimeoutError",
"PlaywrightTool",
"PlaywrightDefaults",
"PlaywrightDownloadResult",
"config_schema",
]
_MODULE_ATTRS = {
"PlaywrightTimeoutError": ".runtime",
"PlaywrightTool": ".runtime",
"PlaywrightDefaults": ".runtime",
"PlaywrightDownloadResult": ".runtime",
"config_schema": ".runtime",
}
def __getattr__(name: str) -> object:
submod = _MODULE_ATTRS.get(name)
if submod is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(submod, package=__name__)
obj = getattr(mod, name)
globals()[name] = obj
return obj
+657
View File
@@ -0,0 +1,657 @@
from __future__ import annotations
import contextlib
import os
import re
import shutil
import tempfile
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterator, Optional, Union
from SYS.config import get_nested_config_value as _get_nested
from SYS.logger import debug
try:
from playwright.sync_api import TimeoutError as _SyncPlaywrightTimeoutError
from playwright.sync_api import sync_playwright
except Exception: # pragma: no cover - handled at runtime
sync_playwright = None
class PlaywrightTimeoutError(RuntimeError):
"""Fallback timeout type when Playwright is unavailable."""
else:
PlaywrightTimeoutError = _SyncPlaywrightTimeoutError
# Re-export for consumers (e.g. cmdlets catching navigation timeouts)
__all__ = [
"PlaywrightTimeoutError",
"PlaywrightTool",
"PlaywrightDefaults",
"PlaywrightDownloadResult",
]
def _resolve_out_dir(arg_outdir: Optional[Union[str, Path]]) -> Path:
"""Resolve an output directory using config when possible."""
if arg_outdir:
p = Path(arg_outdir)
p.mkdir(parents=True, exist_ok=True)
return p
try:
from SYS.config import load_config, resolve_output_dir
cfg = load_config()
p = resolve_output_dir(cfg)
try:
p.mkdir(parents=True, exist_ok=True)
except Exception:
from SYS.logger import logger
logger.exception("Failed to create resolved output dir %s", p)
return p
except Exception:
return Path(tempfile.mkdtemp(prefix="pwdl_"))
def _find_filename_from_cd(cd: str) -> Optional[str]:
if not cd:
return None
m = re.search(r"filename\*?=(?:UTF-8''\s*)?\"?([^\";]+)\"?", cd)
if m:
return m.group(1)
return None
@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"
)
viewport_width: int = 1920
viewport_height: int = 1080
navigation_timeout_ms: int = 90_000
ignore_https_errors: bool = True
screenshot_quality: int = 8
ffmpeg_path: Optional[str] = None # Path to ffmpeg executable; auto-detected if None
@dataclass(slots=True)
class PlaywrightDownloadResult:
ok: bool
path: Optional[Path] = None
url: Optional[str] = None
mode: Optional[str] = None
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"ok": bool(self.ok),
"path": str(self.path) if self.path else None,
"url": self.url,
"mode": self.mode,
"error": self.error,
}
class PlaywrightTool:
"""Small wrapper to standardize Playwright defaults and lifecycle.
This is meant to keep cmdlets/providers from duplicating:
- sync_playwright start/stop
- browser launch/context creation
- user-agent/viewport defaults
- ffmpeg path resolution (for video recording)
Config overrides (plugin.playwright keys):
- plugin.playwright.browser="chromium"
- plugin.playwright.headless=true
- plugin.playwright.user_agent="..."
- plugin.playwright.viewport_width=1280
- plugin.playwright.viewport_height=1200
- plugin.playwright.navigation_timeout_ms=90000
- plugin.playwright.ignore_https_errors=true
- plugin.playwright.screenshot_quality=8
- plugin.playwright.ffmpeg_path="/path/to/ffmpeg" (auto-detected if not set)
FFmpeg resolution (in order):
1. Config key: playwright.ffmpeg_path
2. Environment variable: FFMPEG_PATH (global shared env)
3. Environment variable: PLAYWRIGHT_FFMPEG_PATH (legacy)
4. Project bundled: MPV/ffmpeg/bin/ffmpeg[.exe]
5. System PATH: which ffmpeg
"""
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
self._config: Dict[str,
Any] = dict(config or {})
self.defaults = self._load_defaults()
def _load_defaults(self) -> PlaywrightDefaults:
cfg = self._config
defaults = PlaywrightDefaults()
pw_block = _get_nested(cfg, "plugin", "playwright")
if not isinstance(pw_block, dict):
pw_block = {}
def _get(name: str, fallback: Any) -> Any:
val = pw_block.get(name)
return fallback if val is None else val
browser = str(_get("browser", defaults.browser)).strip().lower() or "chromium"
if browser not in {"chromium",
"firefox",
"webkit"}:
browser = "chromium"
headless_raw = _get("headless", defaults.headless)
headless = bool(headless_raw)
# Resolve user_agent configuration. Support a 'custom' token which reads
# the actual UA from `user_agent_custom` so the UI can offer a simple
# dropdown without scattering long UA strings to users by default.
ua_raw = _get("user_agent", None)
if ua_raw is None:
ua = defaults.user_agent
else:
ua_s = str(ua_raw).strip()
if ua_s.lower() == "custom":
ua_custom = _get("user_agent_custom", "")
ua = str(ua_custom).strip() or defaults.user_agent
else:
ua = ua_s
def _int(name: str, fallback: int) -> int:
raw = _get(name, fallback)
try:
return int(raw)
except Exception:
return fallback
vw = _int("viewport_width", defaults.viewport_width)
vh = _int("viewport_height", defaults.viewport_height)
nav_timeout = _int("navigation_timeout_ms", defaults.navigation_timeout_ms)
screenshot_quality = max(1, min(10, _int("screenshot_quality", defaults.screenshot_quality)))
ignore_https = bool(_get("ignore_https_errors", defaults.ignore_https_errors))
# Try to find ffmpeg: config override, global env FFMPEG_PATH, legacy
# PLAYWRIGHT_FFMPEG_PATH, bundled, then system. This allows Playwright to
# use the shared ffmpeg path used by other tools (FFMPEG_PATH env).
ffmpeg_path: Optional[str] = None
config_ffmpeg = _get("ffmpeg_path", None)
if config_ffmpeg:
# User explicitly configured ffmpeg path
candidate = str(config_ffmpeg).strip()
if candidate and Path(candidate).exists():
ffmpeg_path = candidate
else:
ffmpeg_path = None
if not ffmpeg_path:
# Prefer a global FFMPEG_PATH env var (shared by tools) before Playwright-specific one
env_ffmpeg = os.environ.get("FFMPEG_PATH")
if env_ffmpeg and Path(env_ffmpeg).exists():
ffmpeg_path = env_ffmpeg
if not ffmpeg_path:
# Backward-compatible Playwright-specific env var
env_ffmpeg2 = os.environ.get("PLAYWRIGHT_FFMPEG_PATH")
if env_ffmpeg2 and Path(env_ffmpeg2).exists():
ffmpeg_path = env_ffmpeg2
if not ffmpeg_path:
# Try to find bundled ffmpeg in the project (Windows-only, in MPV/ffmpeg/bin)
try:
repo_root = Path(__file__).resolve().parents[2]
bundled_ffmpeg = repo_root / "MPV" / "ffmpeg" / "bin"
if bundled_ffmpeg.exists():
ffmpeg_exe = bundled_ffmpeg / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
if ffmpeg_exe.exists():
ffmpeg_path = str(ffmpeg_exe)
except Exception:
pass
if not ffmpeg_path:
# Try system ffmpeg if bundled not found
system_ffmpeg = shutil.which("ffmpeg")
if system_ffmpeg:
ffmpeg_path = system_ffmpeg
return PlaywrightDefaults(
browser=browser,
headless=headless,
user_agent=ua,
viewport_width=vw,
viewport_height=vh,
navigation_timeout_ms=nav_timeout,
ignore_https_errors=ignore_https,
screenshot_quality=screenshot_quality,
ffmpeg_path=ffmpeg_path,
)
def require(self) -> None:
"""Ensure Playwright is present; raise a helpful RuntimeError if not."""
try:
assert sync_playwright is not None
except Exception:
raise RuntimeError(
"playwright is required; install with: pip install playwright; then: playwright install"
)
def ffmpeg_available(self) -> bool:
"""Check if ffmpeg is available on the system."""
return bool(self.defaults.ffmpeg_path)
def require_ffmpeg(self) -> None:
"""Require ffmpeg to be available; raise a helpful error if not.
This should be called before operations that need ffmpeg (e.g., video recording).
"""
if not self.ffmpeg_available():
raise RuntimeError(
"ffmpeg is required but not found on your system.\n"
"Install it using:\n"
" Windows: choco install ffmpeg (if using Chocolatey) or use the bundled version in MPV/ffmpeg\n"
" macOS: brew install ffmpeg\n"
" Linux: apt install ffmpeg (Ubuntu/Debian) or equivalent for your distribution\n"
"\n"
"Or set the PLAYWRIGHT_FFMPEG_PATH environment variable to point to your ffmpeg executable."
)
@contextlib.contextmanager
def open_page(
self,
*,
headless: Optional[bool] = None,
user_agent: Optional[str] = None,
viewport_width: Optional[int] = None,
viewport_height: Optional[int] = None,
ignore_https_errors: Optional[bool] = None,
accept_downloads: bool = False,
emulate_viewport: bool = True,
start_maximized: bool = False,
) -> Iterator[Any]:
"""Context manager yielding a Playwright page with sane defaults."""
self.require()
h = self.defaults.headless if headless is None else bool(headless)
ua = self.defaults.user_agent if user_agent is None else str(user_agent)
vw = self.defaults.viewport_width if viewport_width is None else int(
viewport_width
)
vh = self.defaults.viewport_height if viewport_height is None else int(
viewport_height
)
ihe = (
self.defaults.ignore_https_errors
if ignore_https_errors is None else bool(ignore_https_errors)
)
# Support Playwright-native headers/user-agent.
# If user_agent is unset/empty or explicitly set to one of these tokens,
# we omit the user_agent override so Playwright uses its bundled Chromium UA.
ua_value: Optional[str]
ua_text = str(ua or "").strip()
if not ua_text or ua_text.lower() in {"native",
"playwright",
"default"}:
ua_value = None
else:
ua_value = ua_text
pw = None
browser = None
context = None
try:
assert sync_playwright is not None
pw = sync_playwright().start()
browser_type = getattr(pw, self.defaults.browser, None)
if browser_type is None:
browser_type = pw.chromium
launch_args = ["--disable-blink-features=AutomationControlled"]
if bool(start_maximized) and not h:
launch_args.append("--start-maximized")
browser = browser_type.launch(
headless=h,
args=launch_args,
)
context_kwargs: Dict[str,
Any] = {
"ignore_https_errors": ihe,
"accept_downloads": bool(accept_downloads),
}
if bool(emulate_viewport):
context_kwargs["viewport"] = {
"width": vw,
"height": vh,
}
else:
context_kwargs["no_viewport"] = True
if ua_value is not None:
context_kwargs["user_agent"] = ua_value
context = browser.new_context(**context_kwargs)
page = context.new_page()
yield page
finally:
try:
if context is not None:
context.close()
except Exception:
from SYS.logger import logger
logger.exception("Failed to close Playwright context")
try:
if browser is not None:
browser.close()
except Exception:
from SYS.logger import logger
logger.exception("Failed to close Playwright browser")
try:
if pw is not None:
pw.stop()
except Exception:
from SYS.logger import logger
logger.exception("Failed to stop Playwright engine")
def goto(self, page: Any, url: str) -> None:
"""Navigate with configured timeout."""
try:
page.goto(
url,
timeout=int(self.defaults.navigation_timeout_ms),
wait_until="domcontentloaded"
)
except Exception:
raise
def download_file(
self,
url: str,
*,
selector: str = "form#dl_form button[type=submit]",
out_dir: Optional[Union[str, Path]] = None,
timeout_sec: int = 60,
headless_first: bool = False,
debug_mode: bool = False,
) -> PlaywrightDownloadResult:
"""Download a file by clicking a selector and capturing the response.
The helper mirrors the standalone `scripts/playwright_fetch.py` logic
and tries multiple click strategies (expect_download, tooltip continue,
submitDL, JS/mouse click) to coax stubborn sites.
"""
try:
self.require()
except Exception as exc:
return PlaywrightDownloadResult(ok=False, error=str(exc))
out_path_base = _resolve_out_dir(out_dir)
timeout_ms = max(10_000, int(timeout_sec) * 1000 if timeout_sec is not None else int(self.defaults.navigation_timeout_ms))
nav_timeout_ms = max(timeout_ms, int(self.defaults.navigation_timeout_ms))
selector_timeout_ms = 10_000
# Preserve legacy behaviour: headless_first=False tries headful then headless; True reverses the order.
order = [True, False] if headless_first else [False, True]
seen = set()
modes = []
for m in order:
if m in seen:
continue
seen.add(m)
modes.append(m)
last_error: Optional[str] = None
for mode in modes:
try:
if debug_mode:
debug(f"[playwright] download url={url} selector={selector} headless={mode} out_dir={out_path_base}")
with self.open_page(headless=mode, accept_downloads=True) as page:
page.goto(url, wait_until="networkidle", timeout=nav_timeout_ms)
page.wait_for_selector(selector, timeout=selector_timeout_ms)
self._wait_for_block_clear(page, timeout_ms=6000)
el = page.query_selector(selector)
# 1) Direct click with expect_download
try:
with page.expect_download(timeout=timeout_ms) as dl_info:
if el:
el.click()
else:
page.click(selector)
dl = dl_info.value
filename = dl.suggested_filename or Path(dl.url).name or "download"
out_path = out_path_base / filename
dl.save_as(str(out_path))
return PlaywrightDownloadResult(ok=True, path=out_path, url=dl.url, mode="download")
except PlaywrightTimeoutError:
last_error = "download timeout"
except Exception as click_exc:
last_error = str(click_exc) or last_error
# 2) Tooltip continue flow
try:
btn = page.query_selector("#tooltip4 input[type=button]")
if btn:
btn.click()
with page.expect_download(timeout=timeout_ms) as dl_info:
if el:
el.click()
else:
page.click(selector)
dl = dl_info.value
filename = dl.suggested_filename or Path(dl.url).name or "download"
out_path = out_path_base / filename
dl.save_as(str(out_path))
return PlaywrightDownloadResult(ok=True, path=out_path, url=dl.url, mode="tooltip-download")
except Exception as tooltip_exc:
last_error = str(tooltip_exc) or last_error
# 3) Submit handler that respects tooltip flow
try:
page.evaluate("() => { try { submitDL(document.forms['dl_form'], 'tooltip4'); } catch (e) {} }")
resp = page.wait_for_response(
lambda r: r.status == 200 and any(k.lower() == 'content-disposition' for k in r.headers.keys()),
timeout=timeout_ms,
)
if resp:
out_path = self._save_response(resp, out_path_base)
if out_path:
return PlaywrightDownloadResult(ok=True, path=out_path, url=getattr(resp, "url", None), mode="response")
except Exception as resp_exc:
last_error = str(resp_exc) or last_error
# 4) JS/mouse click and capture response
try:
if el:
try:
page.evaluate("el => el.click()", el)
except Exception:
page.evaluate(f"() => document.querySelector('{selector}').click()")
else:
page.evaluate(f"() => document.querySelector('{selector}').click()")
if el:
try:
box = el.bounding_box()
if box:
page.mouse.move(box['x'] + box['width'] / 2, box['y'] + box['height'] / 2)
page.mouse.click(box['x'] + box['width'] / 2, box['y'] + box['height'] / 2)
except Exception:
from SYS.logger import logger
logger.exception("Failed to perform mouse click for selector '%s'", selector)
resp = page.wait_for_response(
lambda r: r.status == 200 and any(k.lower() == 'content-disposition' for k in r.headers.keys()),
timeout=timeout_ms,
)
if resp:
out_path = self._save_response(resp, out_path_base)
if out_path:
return PlaywrightDownloadResult(ok=True, path=out_path, url=getattr(resp, "url", None), mode="response-fallback")
except Exception as final_exc:
last_error = str(final_exc) or last_error
except Exception as exc:
last_error = str(exc)
if debug_mode:
try:
debug(f"[playwright] attempt failed (headless={mode}): {traceback.format_exc()}")
except Exception:
from SYS.logger import logger
logger.exception("Failed to emit debug info for Playwright attempt failure")
continue
return PlaywrightDownloadResult(ok=False, error=last_error or "no download captured")
def debug_dump(self) -> None:
try:
debug(
f"[playwright] browser={self.defaults.browser} headless={self.defaults.headless} "
f"viewport={self.defaults.viewport_width}x{self.defaults.viewport_height} "
f"nav_timeout_ms={self.defaults.navigation_timeout_ms}"
)
except Exception:
from SYS.logger import logger
logger.exception("Failed to debug_dump Playwright defaults")
def _wait_for_block_clear(self, page: Any, timeout_ms: int = 8000) -> bool:
try:
page.wait_for_function(
"() => { for (const k in window) { if (Object.prototype.hasOwnProperty.call(window, k) && k.startsWith('blocked_')) { try { return window[k] === false; } catch(e) {} return false; } } return true; }",
timeout=timeout_ms,
)
return True
except Exception:
return False
def _save_response(self, response: Any, out_dir: Path) -> Optional[Path]:
try:
cd = ""
try:
headers = getattr(response, "headers", {}) or {}
cd = "".join([v for k, v in headers.items() if str(k).lower() == "content-disposition"])
except Exception:
cd = ""
filename = _find_filename_from_cd(cd) or Path(str(getattr(response, "url", "") or "")).name or "download"
body = response.body()
out_path = out_dir / filename
out_path.write_bytes(body)
return out_path
except Exception as exc:
try:
debug(f"[playwright] failed to save response: {exc}")
except Exception:
pass
return None
def config_schema() -> List[Dict[str, Any]]:
"""Return a schema describing editable Playwright tool defaults for the config UI.
Notes:
- `user_agent` is a dropdown with a `custom` option; put the real UA in
`user_agent_custom` when choosing `custom`.
- Viewport dimensions are offered as convenient choices.
- `ffmpeg_path` intentionally defaults to empty; Playwright will consult
a global `FFMPEG_PATH` environment variable (or fallback to bundled/system).
"""
_defaults = PlaywrightDefaults()
browser_choices = ["chromium", "firefox", "webkit"]
viewport_width_choices = [1920, 1366, 1280, 1024, 800]
viewport_height_choices = [1080, 900, 768, 720, 600]
return [
{
"key": "browser",
"label": "Playwright browser",
"group": "Browser",
"default": _defaults.browser,
"choices": browser_choices,
},
{
"key": "headless",
"label": "Headless",
"group": "Browser",
"type": "boolean",
"default": str(_defaults.headless),
"choices": ["true", "false"],
},
{
"key": "user_agent",
"label": "User Agent",
"group": "Browser",
"default": "default",
"choices": ["default", "native", "custom"],
},
{
"key": "user_agent_custom",
"label": "Custom User Agent (used when User Agent = custom)",
"group": "Browser",
"default": "",
"placeholder": "Mozilla/5.0 ...",
},
{
"key": "viewport_width",
"label": "Viewport width",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_width,
"choices": viewport_width_choices,
},
{
"key": "viewport_height",
"label": "Viewport height",
"group": "Capture",
"type": "integer",
"default": _defaults.viewport_height,
"choices": viewport_height_choices,
},
{
"key": "navigation_timeout_ms",
"label": "Navigation timeout (ms)",
"group": "Navigation",
"type": "integer",
"default": _defaults.navigation_timeout_ms,
},
{
"key": "screenshot_quality",
"label": "Default screenshot quality",
"group": "Capture",
"type": "integer",
"default": _defaults.screenshot_quality,
"choices": list(range(1, 11)),
},
{
"key": "ignore_https_errors",
"label": "Ignore HTTPS errors",
"group": "Navigation",
"type": "boolean",
"default": str(_defaults.ignore_https_errors),
"choices": ["true", "false"],
},
{
"key": "ffmpeg_path",
"label": "FFmpeg path (leave empty to use global/bundled)",
"group": "Environment",
"type": "path",
"default": "",
"placeholder": "C:/path/to/ffmpeg.exe",
},
]
+7 -7
View File
@@ -11,11 +11,11 @@ from SYS.utils import format_bytes
def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
provider = config.get("provider")
if not isinstance(provider, dict):
plugin_cfg = config.get("plugin")
if not isinstance(plugin_cfg, dict):
return "", ""
entry = provider.get("podcastindex")
entry = plugin_cfg.get("podcastindex")
if not isinstance(entry, dict):
return "", ""
@@ -290,7 +290,7 @@ class PodcastIndex(Provider):
pass
try:
from API.HTTP import _download_direct_file
from API.HTTP import download_direct_file
except Exception:
return True
@@ -308,7 +308,7 @@ class PodcastIndex(Provider):
title_hint = str(item.get("title") or md.get("title") or "episode").strip() or "episode"
try:
result_obj = _download_direct_file(
result_obj = download_direct_file(
enc_url,
Path(output_dir),
quiet=False,
@@ -357,12 +357,12 @@ class PodcastIndex(Provider):
"path": str(local_path),
"hash": sha256,
"title": title_hint,
"action": "provider:podcastindex.selector",
"action": "plugin:podcastindex.selector",
"download_mode": "file",
"store": "local",
"media_kind": "audio",
"tag": tags,
"provider": "podcastindex",
"plugin": "podcastindex",
"url": enc_url,
}
if isinstance(md, dict) and md:
+2 -2
View File
@@ -390,7 +390,7 @@ class SCP(Provider):
table.set_table("scp")
try:
table.set_table_metadata({
"provider": "scp",
"plugin": "scp",
"instance": instance_name or None,
"host": settings.get("host"),
"path": target_path,
@@ -958,7 +958,7 @@ class SCP(Provider):
parent = posixpath.dirname(scp_path.rstrip("/")) or "/"
instance_name = str(settings.get("instance") or "").strip()
metadata = {
"provider": "scp",
"plugin": "scp",
"instance": instance_name or None,
"host": settings.get("host"),
"scp_path": scp_path,
+1 -1
View File
@@ -690,7 +690,7 @@ class Soulseek(Provider):
"album": item["album"],
"track_num": item["track_num"],
"ext": item["ext"],
"provider": "soulseek"
"plugin": "soulseek"
},
)
)
+2 -2
View File
@@ -194,7 +194,7 @@ class Telegram(Provider):
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
telegram_conf = (
self.config.get("provider",
self.config.get("plugin",
{}).get("telegram",
{}) if isinstance(self.config,
dict) else {}
@@ -1280,7 +1280,7 @@ class Telegram(Provider):
info: Dict[str,
Any] = {
"provider": "telegram",
"plugin": "telegram",
"source_url": url,
"chat": {
"key": chat,
+7 -7
View File
@@ -10,7 +10,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from plugins.tidal.api import (
Tidal as TidalApiClient,
Tidal,
build_track_tags,
coerce_duration_seconds,
extract_artists,
@@ -208,7 +208,7 @@ class Tidal(Provider):
self.api_timeout = float(self.config.get("timeout", 10.0))
except Exception:
self.api_timeout = 10.0
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
self.api_clients = [Tidal(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]:
return resolve_tidal_manifest_path(item)
@@ -960,7 +960,7 @@ class Tidal(Provider):
try:
table.set_table_metadata(
{
"provider": "tidal",
"plugin": "tidal",
"view": "track",
"album_id": album_id,
"album_title": album_title,
@@ -1670,7 +1670,7 @@ class Tidal(Provider):
return downloaded_count
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
def _get_api_client_for_base(self, base_url: str) -> Optional[Tidal]:
base = base_url.rstrip("/")
for client in self.api_clients:
if getattr(client, "base_url", "").rstrip("/") == base:
@@ -2206,7 +2206,7 @@ class Tidal(Provider):
table = Table(f"Tidal Albums: {artist_name}")._perseverance(False)
table.set_table("tidal.album")
try:
table.set_table_metadata({"provider": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
table.set_table_metadata({"plugin": "tidal", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
except Exception:
pass
@@ -2268,7 +2268,7 @@ class Tidal(Provider):
try:
table.set_table_metadata(
{
"provider": "tidal",
"plugin": "tidal",
"view": "track",
"album_id": album_id,
"album_title": album_title,
@@ -2338,7 +2338,7 @@ class Tidal(Provider):
table = Table("Tidal Track")._perseverance(True)
table.set_table("tidal.track")
try:
table.set_table_metadata({"provider": "tidal", "view": "track", "resolved_manifest": True})
table.set_table_metadata({"plugin": "tidal", "view": "track", "resolved_manifest": True})
except Exception:
pass
results_payload: List[Dict[str, Any]] = []
-5
View File
@@ -320,8 +320,3 @@ class Tidal(API):
border_style="cyan",
)
return res
# Legacy alias for TidalApiClient
TidalApiClient = Tidal
HifiApiClient = Tidal
+1 -1
View File
@@ -20,7 +20,7 @@ from PluginCore.base import Provider, SearchResult, parse_inline_query_arguments
from PluginCore.inline_utils import resolve_filter
from SYS.logger import debug, debug_panel
from SYS.plugin_helpers import TablePluginMixin
from tool.playwright import PlaywrightTool
from plugins.playwright import PlaywrightTool
class Vimm(TablePluginMixin, Provider):
+6 -1
View File
@@ -23,12 +23,13 @@ from SYS.result_table import Table
from SYS.rich_display import stderr_console as get_stderr_console
from SYS import pipeline as pipeline_context
from SYS.utils import sha256_file
from tool.ytdlp import (
from .tooling import (
YtDlpTool,
_best_subtitle_sidecar,
_SUBTITLE_EXTS,
_download_with_timeout,
_format_chapters_note,
config_schema as _ytdlp_config_schema,
_read_text_file,
collapse_picker_formats,
format_for_table_selection,
@@ -508,6 +509,10 @@ class ytdlp(TablePluginMixin, Provider):
PLUGIN_ALIASES = ("youtube",)
SEARCH_QUERY_KEYS = ("search", "q")
@staticmethod
def config_schema() -> List[Dict[str, Any]]:
return _ytdlp_config_schema()
@classmethod
def url_patterns(cls) -> Tuple[str, ...]:
try:
File diff suppressed because it is too large Load Diff