This commit is contained in:
2026-01-09 01:22:06 -08:00
parent 89ac3bb7e8
commit 1deddfda5c
10 changed files with 1004 additions and 179 deletions

View File

@@ -11,6 +11,7 @@ from urllib.parse import urlparse
from API.HTTP import HTTPClient, _download_direct_file
from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_torrent_file
from ProviderCore.base import Provider, SearchResult
from SYS.provider_helpers import TableProviderMixin
from SYS.utils import sanitize_filename
from SYS.logger import log, debug
from SYS.models import DownloadError
@@ -541,7 +542,32 @@ def adjust_output_dir_for_alldebrid(
return output_dir
class AllDebrid(Provider):
class AllDebrid(TableProviderMixin, Provider):
"""AllDebrid account provider with magnet folder/file browsing and downloads.
This provider uses the new table system (strict ResultTable adapter pattern) for
consistent selection and auto-stage integration across all providers. It exposes
magnets as folder rows and files as file rows, with metadata enrichment for:
- magnet_id: For routing to _download_magnet_by_id
- status/ready: For showing sync state
- _selection_args/_selection_action: For @N expansion control
- relpath: For proper file hierarchy in downloads
KEY FEATURES:
- Table system: Using ResultTable adapter for strict column/metadata handling
- Selection override: Full metadata control via _selection_args/_selection_action
- Auto-stages: download-file is auto-inserted when @N is used on magnet folders
- File unlocking: URLs with /f/ paths are automatically unlocked via API before download
- Drill-down: Selecting a folder row (@N) fetches and displays all files
SELECTION FLOW:
1. User runs: search-file -provider alldebrid "ubuntu"
2. Results show magnet folders and (optionally) files
3. User selects a row: @1
4. Selection metadata routes to download-file with -magnet-id
5. download-file calls provider.download_items() with magnet_id
6. Provider fetches files, unlocks locked URLs, and downloads
"""
# Magnet URIs should be routed through this provider.
TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]}
AUTO_STAGE_USE_SELECTION_ARGS = True
@@ -1147,6 +1173,7 @@ class AllDebrid(Provider):
"file": file_node,
"provider": "alldebrid",
"provider_view": "files",
# Selection metadata for table system
"_selection_args": ["-magnet-id", str(magnet_id)],
"_selection_action": ["download-file", "-provider", "alldebrid", "-magnet-id", str(magnet_id)],
}
@@ -1521,6 +1548,12 @@ try:
def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]:
"""Build column specifications from available metadata in rows.
This factory inspects all rows and creates ColumnSpec entries only
for metadata that is actually present in the result set. This avoids
empty columns in the display.
"""
cols = [title_column()]
if _has_metadata(rows, "magnet_name"):
cols.append(metadata_column("magnet_name", "Magnet"))
@@ -1531,7 +1564,7 @@ try:
if _has_metadata(rows, "ready"):
cols.append(metadata_column("ready", "Ready"))
if _has_metadata(rows, "relpath"):
cols.append(metadata_column("relpath", "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, "size"):
@@ -1540,22 +1573,45 @@ try:
def _selection_fn(row: ResultModel) -> List[str]:
"""Return selection args for @N expansion and auto-download integration.
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)
4. Magnet ID routing for folder-type rows
5. Direct URL for file rows
This ensures that selector overrides all pre-codes and gives users full power.
"""
metadata = row.metadata or {}
# First try explicit action (full command)
action = metadata.get("_selection_action") or metadata.get("selection_action")
if isinstance(action, (list, tuple)) and action:
return [str(x) for x in action if x is not None]
# Next try explicit args (typically URL-based)
args = metadata.get("_selection_args") or metadata.get("selection_args")
if isinstance(args, (list, tuple)) and args:
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 ""
if view == "files":
# File rows: pass direct URL for immediate download
if row.path:
return ["-url", row.path]
# Folder rows: use magnet_id to fetch and download all files
magnet_id = metadata.get("magnet_id")
if magnet_id is not None:
return ["-magnet-id", str(magnet_id)]
# Fallback: try direct URL
if row.path:
return ["-url", row.path]
return ["-title", row.title or ""]

View File

@@ -1,15 +1,18 @@
from __future__ import annotations
import mimetypes
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import quote
import requests
from ProviderCore.base import Provider
from ProviderCore.base import Provider, SearchResult
from SYS.provider_helpers import TableProviderMixin
from SYS.logger import log
_MATRIX_INIT_CHECK_CACHE: Dict[str,
Tuple[bool,
@@ -207,8 +210,29 @@ def _matrix_health_check(*,
return False, str(exc)
class Matrix(Provider):
"""File provider for Matrix (Element) chat rooms."""
class Matrix(TableProviderMixin, Provider):
"""Matrix (Element) room provider with file uploads and selection.
This provider uses the new table system (strict ResultTable adapter pattern) for
consistent room listing and selection. It exposes Matrix joined rooms as selectable
rows with metadata enrichment for:
- room_id: Unique Matrix room identifier
- room_name: Human-readable room display name
- _selection_args: For @N expansion control and upload routing
KEY FEATURES:
- Table system: Using ResultTable adapter for strict column/metadata handling
- Room discovery: search() or list_rooms() to enumerate joined rooms
- Selection integration: @N selection triggers upload_to_room() via selector()
- Deferred uploads: Files can be queued for upload to multiple rooms
- MIME detection: Automatic content type classification for Matrix msgtype
SELECTION FLOW:
1. User runs: search-file -provider matrix "room" (or .matrix -list-rooms)
2. Results show available joined rooms
3. User selects rooms: @1 @2 (or @1,2)
4. Selection triggers upload of pending files to selected rooms
"""
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
@@ -261,6 +285,70 @@ class Matrix(Provider):
and (matrix_conf.get("access_token") or matrix_conf.get("password"))
)
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
"""Search/list joined Matrix rooms.
If query is empty or "*", returns all joined rooms.
Otherwise, filters rooms by name/ID matching the query.
"""
try:
rooms = self.list_rooms()
except Exception as exc:
log(f"[matrix] Failed to list rooms: {exc}", file=sys.stderr)
return []
q = (query or "").strip().lower()
needle = "" if q in {"*", "all", "list"} else q
results: List[SearchResult] = []
for room in rooms:
if len(results) >= limit:
break
room_id = room.get("room_id") or ""
room_name = room.get("name") or ""
# Filter by query if provided
if needle:
match_text = f"{room_name} {room_id}".lower()
if needle not in match_text:
continue
if not room_id:
continue
display_name = room_name or room_id
results.append(
SearchResult(
table="matrix",
title=display_name,
path=f"matrix:room:{room_id}",
detail=room_id if room_name else "",
annotations=["room"],
media_kind="folder",
columns=[
("Room", display_name),
("ID", room_id),
],
full_metadata={
"room_id": room_id,
"room_name": room_name,
"provider": "matrix",
# Selection metadata for table system and @N expansion
"_selection_args": ["-room-id", room_id],
},
)
)
return results
def _get_homeserver_and_token(self) -> Tuple[str, str]:
matrix_conf = self.config.get("provider",
{}).get("matrix",
@@ -595,3 +683,100 @@ class Matrix(Provider):
if any_failed:
print("\nOne or more Matrix uploads failed\n")
return True
# Minimal provider registration for the new table system
try:
from SYS.result_table_adapters import register_provider
from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column
def _convert_search_result_to_model(sr: Any) -> ResultModel:
"""Convert Matrix SearchResult to ResultModel for strict table display."""
d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {"title": getattr(sr, "title", str(sr))})
title = d.get("title") or ""
path = d.get("path") or None
columns = d.get("columns") or getattr(sr, "columns", None) or []
# Extract metadata from columns and full_metadata
metadata: Dict[str, Any] = {}
for name, value in columns:
key = str(name or "").strip().lower()
if key in ("room_id", "room_name", "id", "name"):
metadata[key] = value
try:
fm = d.get("full_metadata") or {}
if isinstance(fm, dict):
for k, v in fm.items():
metadata[str(k).strip().lower()] = v
except Exception:
pass
return ResultModel(
title=str(title),
path=str(path) if path else None,
ext=None,
size_bytes=None,
metadata=metadata,
source="matrix"
)
def _adapter(items: Iterable[Any]) -> Iterable[ResultModel]:
"""Adapter to convert SearchResults to ResultModels."""
for it in items:
try:
yield _convert_search_result_to_model(it)
except Exception:
continue
def _has_metadata(rows: List[ResultModel], key: str) -> bool:
"""Check if any row has a given metadata key with a non-empty value."""
for row in rows:
md = row.metadata or {}
if key in md:
val = md[key]
if val is None:
continue
if isinstance(val, str) and not val.strip():
continue
return True
return False
def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]:
"""Build column specifications from available metadata in rows."""
cols = [title_column()]
if _has_metadata(rows, "room_id"):
cols.append(metadata_column("room_id", "Room ID"))
if _has_metadata(rows, "room_name"):
cols.append(metadata_column("room_name", "Name"))
return cols
def _selection_fn(row: ResultModel) -> List[str]:
"""Return selection args for @N expansion and room selection.
Uses explicit -room-id flag to identify the selected room for file uploads.
"""
metadata = row.metadata or {}
# Check for explicit selection args first
args = metadata.get("_selection_args") or metadata.get("selection_args")
if isinstance(args, (list, tuple)) and args:
return [str(x) for x in args if x is not None]
# Fallback to room_id
room_id = metadata.get("room_id")
if room_id:
return ["-room-id", str(room_id)]
return ["-title", row.title or ""]
register_provider(
"matrix",
_adapter,
columns=_columns_factory,
selection_fn=_selection_fn,
metadata={"description": "Matrix room provider with file uploads"},
)
except Exception:
# best-effort registration
pass

View File

@@ -1,14 +1,32 @@
from __future__ import annotations
import sys
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Iterable, List, Optional
from ProviderCore.base import Provider, SearchResult
from SYS.provider_helpers import TableProviderMixin
from SYS.logger import log
class YouTube(Provider):
"""Search provider for YouTube using the yt_dlp Python package."""
class YouTube(TableProviderMixin, Provider):
"""YouTube video search provider using yt_dlp.
This provider uses the new table system (strict ResultTable adapter pattern) for
consistent selection and auto-stage integration. It exposes videos as SearchResult
rows with metadata enrichment for:
- video_id: Unique YouTube video identifier
- uploader: Channel/creator name
- duration: Video length in seconds
- view_count: Number of views
- _selection_args: For @N expansion control and download-file routing
SELECTION FLOW:
1. User runs: search-file -provider youtube "linux tutorial"
2. Results show video rows with uploader, duration, views
3. User selects a video: @1
4. Selection metadata routes to download-file with the YouTube URL
5. download-file uses yt_dlp to download the video
"""
TABLE_AUTO_STAGES = {
"youtube": ["download-file"],
@@ -79,6 +97,8 @@ class YouTube(Provider):
"uploader": uploader,
"duration": duration,
"view_count": view_count,
# Selection metadata for table system and @N expansion
"_selection_args": ["-url", url],
},
)
)
@@ -94,3 +114,102 @@ class YouTube(Provider):
return True
except Exception:
return False
# Minimal provider registration for the new table system
try:
from SYS.result_table_adapters import register_provider
from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column
def _convert_search_result_to_model(sr: Any) -> ResultModel:
"""Convert YouTube SearchResult to ResultModel for strict table display."""
d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {"title": getattr(sr, "title", str(sr))})
title = d.get("title") or ""
path = d.get("path") or None
columns = d.get("columns") or getattr(sr, "columns", None) or []
# Extract metadata from columns and full_metadata
metadata: Dict[str, Any] = {}
for name, value in columns:
key = str(name or "").strip().lower()
if key in ("uploader", "duration", "views", "video_id"):
metadata[key] = value
try:
fm = d.get("full_metadata") or {}
if isinstance(fm, dict):
for k, v in fm.items():
metadata[str(k).strip().lower()] = v
except Exception:
pass
return ResultModel(
title=str(title),
path=str(path) if path else None,
ext=None,
size_bytes=None,
metadata=metadata,
source="youtube"
)
def _adapter(items: Iterable[Any]) -> Iterable[ResultModel]:
"""Adapter to convert SearchResults to ResultModels."""
for it in items:
try:
yield _convert_search_result_to_model(it)
except Exception:
continue
def _has_metadata(rows: List[ResultModel], key: str) -> bool:
"""Check if any row has a given metadata key with a non-empty value."""
for row in rows:
md = row.metadata or {}
if key in md:
val = md[key]
if val is None:
continue
if isinstance(val, str) and not val.strip():
continue
return True
return False
def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]:
"""Build column specifications from available metadata in rows."""
cols = [title_column()]
if _has_metadata(rows, "uploader"):
cols.append(metadata_column("uploader", "Uploader"))
if _has_metadata(rows, "duration"):
cols.append(metadata_column("duration", "Duration"))
if _has_metadata(rows, "views"):
cols.append(metadata_column("views", "Views"))
return cols
def _selection_fn(row: ResultModel) -> List[str]:
"""Return selection args for @N expansion and auto-download integration.
Uses explicit -url flag to ensure the YouTube URL is properly routed
to download-file for yt_dlp download processing.
"""
metadata = row.metadata or {}
# Check for explicit selection args first
args = metadata.get("_selection_args") or metadata.get("selection_args")
if isinstance(args, (list, tuple)) and args:
return [str(x) for x in args if x is not None]
# Fallback to direct URL
if row.path:
return ["-url", row.path]
return ["-title", row.title or ""]
register_provider(
"youtube",
_adapter,
columns=_columns_factory,
selection_fn=_selection_fn,
metadata={"description": "YouTube video search using yt_dlp"},
)
except Exception:
# best-effort registration
pass