This commit is contained in:
nose
2025-12-19 02:29:42 -08:00
parent d637532237
commit 52cf3f5c9f
24 changed files with 1284 additions and 176 deletions

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import sys
from typing import Any, Dict, Iterable, List, Optional
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from ProviderCore.download import sanitize_filename
from SYS.logger import log
@@ -53,7 +53,7 @@ def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
return None
class AllDebrid(SearchProvider):
class AllDebrid(Provider):
"""Search provider for AllDebrid account content.
This provider lists and searches the files/magnets already present in the

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
from typing import Any, Dict, List, Optional
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log, debug
try:
@@ -12,7 +12,7 @@ except ImportError: # pragma: no cover
sync_playwright = None
class Bandcamp(SearchProvider):
class Bandcamp(Provider):
"""Search provider for Bandcamp."""
def search(

View File

@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urljoin, urlparse, unquote
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from ProviderCore.download import sanitize_filename
from SYS.logger import log
from models import ProgressBar
@@ -22,7 +22,7 @@ except ImportError:
lxml_html = None
class Libgen(SearchProvider):
class Libgen(Provider):
"""Search provider for Library Genesis books."""
def search(

View File

@@ -9,7 +9,7 @@ from urllib.parse import quote
import requests
from ProviderCore.base import FileProvider
from ProviderCore.base import Provider
_MATRIX_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {}
@@ -50,7 +50,7 @@ def _matrix_health_check(*, homeserver: str, access_token: Optional[str]) -> Tup
return False, str(exc)
class Matrix(FileProvider):
class Matrix(Provider):
"""File provider for Matrix (Element) chat rooms."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
@@ -208,3 +208,82 @@ class Matrix(FileProvider):
if not room_id:
raise Exception("Matrix room_id missing")
return self.upload_to_room(file_path, str(room_id))
def selector(self, selected_items: List[Any], *, ctx: Any, stage_is_last: bool = True, **_kwargs: Any) -> bool:
"""Handle Matrix room selection via `@N`.
If the CLI has a pending upload stash, selecting a room triggers an upload.
"""
if not stage_is_last:
return False
pending = None
try:
pending = ctx.load_value('matrix_pending_uploads', default=None)
except Exception:
pending = None
pending_list = list(pending) if isinstance(pending, list) else []
if not pending_list:
return False
room_ids: List[str] = []
for item in selected_items or []:
rid = None
if isinstance(item, dict):
rid = item.get('room_id') or item.get('id')
else:
rid = getattr(item, 'room_id', None) or getattr(item, 'id', None)
if rid and str(rid).strip():
room_ids.append(str(rid).strip())
if not room_ids:
print("No Matrix room selected\n")
return True
any_failed = False
for room_id in room_ids:
for payload in pending_list:
try:
file_path = ''
delete_after = False
if isinstance(payload, dict):
file_path = str(payload.get('path') or '')
delete_after = bool(payload.get('delete_after', False))
else:
file_path = str(getattr(payload, 'path', '') or '')
if not file_path:
any_failed = True
continue
media_path = Path(file_path)
if not media_path.exists():
any_failed = True
print(f"Matrix upload file missing: {file_path}")
continue
link = self.upload_to_room(str(media_path), str(room_id))
if link:
print(link)
if delete_after:
try:
media_path.unlink(missing_ok=True) # type: ignore[arg-type]
except TypeError:
try:
if media_path.exists():
media_path.unlink()
except Exception:
pass
except Exception as exc:
any_failed = True
print(f"Matrix upload failed: {exc}")
try:
ctx.store_value('matrix_pending_uploads', [])
except Exception:
pass
if any_failed:
print("\nOne or more Matrix uploads failed\n")
return True

View File

@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from API.HTTP import HTTPClient
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from ProviderCore.download import download_file, sanitize_filename
from cli_syntax import get_field, get_free_text, parse_query
from SYS.logger import debug, log
@@ -183,7 +183,7 @@ def _resolve_archive_id(session: requests.Session, edition_id: str, ia_candidate
return ""
class OpenLibrary(SearchProvider):
class OpenLibrary(Provider):
"""Search provider for OpenLibrary books + Archive.org direct/borrow download."""
def __init__(self, config: Optional[Dict[str, Any]] = None):

View File

@@ -11,7 +11,7 @@ import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log, debug
from models import ProgressBar
@@ -153,7 +153,7 @@ def _suppress_aioslsk_noise() -> Any:
sys.stdout, sys.stderr = old_out, old_err
class Soulseek(SearchProvider):
class Soulseek(Provider):
"""Search provider for Soulseek P2P network."""
MUSIC_EXTENSIONS = {

284
Provider/telegram.py Normal file
View File

@@ -0,0 +1,284 @@
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlparse
from ProviderCore.base import Provider, SearchResult
def _looks_like_telegram_message_url(url: str) -> bool:
try:
parsed = urlparse(str(url))
except Exception:
return False
host = (parsed.hostname or "").lower().strip()
if host in {"t.me", "telegram.me"}:
return True
if host.endswith(".t.me"):
return True
return False
def _parse_telegram_message_url(url: str) -> Tuple[str, int]:
"""Parse a Telegram message URL into (entity, message_id).
Supported:
- https://t.me/<username>/<msg_id>
- https://t.me/s/<username>/<msg_id>
- https://t.me/c/<internal_channel_id>/<msg_id>
"""
parsed = urlparse(str(url))
path = (parsed.path or "").strip("/")
if not path:
raise ValueError(f"Invalid Telegram URL: {url}")
parts = [p for p in path.split("/") if p]
if not parts:
raise ValueError(f"Invalid Telegram URL: {url}")
# Strip preview prefix
if parts and parts[0].lower() == "s":
parts = parts[1:]
if len(parts) < 2:
raise ValueError(f"Invalid Telegram URL (expected /<chat>/<msg>): {url}")
chat = parts[0]
msg_raw = parts[1]
# t.me/c/<id>/<msg>
if chat.lower() == "c":
if len(parts) < 3:
raise ValueError(f"Invalid Telegram /c/ URL: {url}")
chat = f"c:{parts[1]}"
msg_raw = parts[2]
m = re.fullmatch(r"\d+", str(msg_raw).strip())
if not m:
raise ValueError(f"Invalid Telegram message id in URL: {url}")
return str(chat), int(msg_raw)
class Telegram(Provider):
"""Telegram provider using Telethon.
Config:
[provider=telegram]
app_id=
api_hash=
"""
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
telegram_conf = self.config.get("provider", {}).get("telegram", {}) if isinstance(self.config, dict) else {}
self._app_id = telegram_conf.get("app_id")
self._api_hash = telegram_conf.get("api_hash")
def validate(self) -> bool:
try:
__import__("telethon")
except Exception:
return False
try:
app_id = int(self._app_id) if self._app_id not in (None, "") else None
except Exception:
app_id = None
api_hash = str(self._api_hash).strip() if self._api_hash not in (None, "") else ""
return bool(app_id and api_hash)
def _session_base_path(self) -> Path:
root = Path(__file__).resolve().parents[1]
session_dir = root / "Log" / "medeia_macina"
try:
session_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return session_dir / "telegram"
def _credentials(self) -> Tuple[int, str]:
raw_app_id = self._app_id
if raw_app_id in (None, ""):
raise Exception("Telegram app_id missing")
try:
app_id = int(str(raw_app_id).strip())
except Exception:
raise Exception("Telegram app_id invalid")
api_hash = str(self._api_hash or "").strip()
if not api_hash:
raise Exception("Telegram api_hash missing")
return app_id, api_hash
def _ensure_event_loop(self) -> None:
"""Telethon sync wrapper requires an event loop to exist in this thread."""
try:
asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def _download_message_media_sync(self, *, url: str, output_dir: Path) -> Tuple[Path, Dict[str, Any]]:
try:
from telethon import errors
from telethon.sync import TelegramClient
from telethon.tl.types import PeerChannel
except Exception as exc:
raise Exception(f"Telethon not available: {exc}")
self._ensure_event_loop()
loop = asyncio.get_event_loop()
if getattr(loop, "is_running", lambda: False)():
raise Exception("Telegram provider cannot run while an event loop is already running")
def _resolve(value):
if asyncio.iscoroutine(value):
return loop.run_until_complete(value)
return value
app_id, api_hash = self._credentials()
session_base = self._session_base_path()
chat, message_id = _parse_telegram_message_url(url)
client = TelegramClient(str(session_base), app_id, api_hash)
try:
# This prompts on first run for phone/code and persists the session.
_resolve(client.start())
if chat.startswith("c:"):
channel_id = int(chat.split(":", 1)[1])
entity = PeerChannel(channel_id)
else:
entity = chat
if isinstance(entity, str) and entity and not entity.startswith("@"):
entity = "@" + entity
# Use the list form to be robust across Telethon sync/async stubs.
messages = _resolve(client.get_messages(entity, ids=[message_id]))
message = None
if isinstance(messages, (list, tuple)):
message = messages[0] if messages else None
else:
try:
# TotalList is list-like
message = messages[0] # type: ignore[index]
except Exception:
message = None
if not message:
raise Exception("Telegram message not found")
if not getattr(message, "media", None):
raise Exception("Telegram message has no media")
chat_title = ""
chat_username = ""
chat_id = None
try:
chat_obj = getattr(message, "chat", None)
if chat_obj is not None:
maybe_title = getattr(chat_obj, "title", None)
maybe_username = getattr(chat_obj, "username", None)
maybe_id = getattr(chat_obj, "id", None)
if isinstance(maybe_title, str):
chat_title = maybe_title.strip()
if isinstance(maybe_username, str):
chat_username = maybe_username.strip()
if maybe_id is not None:
chat_id = int(maybe_id)
except Exception:
pass
caption = ""
try:
maybe_caption = getattr(message, "message", None)
if isinstance(maybe_caption, str):
caption = maybe_caption.strip()
except Exception:
pass
msg_id = None
msg_date = None
try:
msg_id = int(getattr(message, "id", 0) or 0)
except Exception:
msg_id = None
try:
msg_date = getattr(message, "date", None)
except Exception:
msg_date = None
file_name = ""
file_mime = ""
file_size = None
try:
file_obj = getattr(message, "file", None)
maybe_name = getattr(file_obj, "name", None)
maybe_mime = getattr(file_obj, "mime_type", None)
maybe_size = getattr(file_obj, "size", None)
if isinstance(maybe_name, str):
file_name = maybe_name.strip()
if isinstance(maybe_mime, str):
file_mime = maybe_mime.strip()
if maybe_size is not None:
file_size = int(maybe_size)
except Exception:
pass
downloaded = _resolve(client.download_media(message, file=str(output_dir)))
if not downloaded:
raise Exception("Telegram download returned no file")
downloaded_path = Path(str(downloaded))
date_iso = None
try:
if msg_date is not None and hasattr(msg_date, "isoformat"):
date_iso = msg_date.isoformat() # type: ignore[union-attr]
except Exception:
date_iso = None
info: Dict[str, Any] = {
"provider": "telegram",
"source_url": url,
"chat": {
"key": chat,
"title": chat_title,
"username": chat_username,
"id": chat_id,
},
"message": {
"id": msg_id,
"date": date_iso,
"caption": caption,
},
"file": {
"name": file_name,
"mime_type": file_mime,
"size": file_size,
"downloaded_path": str(downloaded_path),
},
}
return downloaded_path, info
except errors.RPCError as exc:
raise Exception(f"Telegram RPC error: {exc}")
finally:
try:
_resolve(client.disconnect())
except Exception:
pass
def download_url(self, url: str, output_dir: Path) -> Tuple[Path, Dict[str, Any]]:
"""Download a Telegram message URL and return (path, metadata)."""
if not _looks_like_telegram_message_url(url):
raise ValueError("Not a Telegram URL")
return self._download_message_media_sync(url=url, output_dir=output_dir)
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
url = str(getattr(result, "path", "") or "")
if not url:
return None
if not _looks_like_telegram_message_url(url):
return None
path, _info = self._download_message_media_sync(url=url, output_dir=output_dir)
return path

View File

@@ -6,11 +6,11 @@ import subprocess
import sys
from typing import Any, Dict, List, Optional
from ProviderCore.base import SearchProvider, SearchResult
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log
class YouTube(SearchProvider):
class YouTube(Provider):
"""Search provider for YouTube using yt-dlp."""
def search(

View File

@@ -4,11 +4,11 @@ import os
import sys
from typing import Any
from ProviderCore.base import FileProvider
from ProviderCore.base import Provider
from SYS.logger import log
class ZeroXZero(FileProvider):
class ZeroXZero(Provider):
"""File provider for 0x0.st."""
def upload(self, file_path: str, **kwargs: Any) -> str: