j
This commit is contained in:
@@ -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 ""]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user