fdsfjlk
This commit is contained in:
150
CLI.py
150
CLI.py
@@ -636,6 +636,7 @@ if (
|
|||||||
and Completion is not None
|
and Completion is not None
|
||||||
and Completer is not None
|
and Completer is not None
|
||||||
and Document is not None
|
and Document is not None
|
||||||
|
and Lexer is not None
|
||||||
):
|
):
|
||||||
CompletionType = cast(Any, Completion)
|
CompletionType = cast(Any, Completion)
|
||||||
|
|
||||||
@@ -934,7 +935,11 @@ def _create_cmdlet_cli():
|
|||||||
prompt_text = "🜂🜄🜁🜃|"
|
prompt_text = "🜂🜄🜁🜃|"
|
||||||
|
|
||||||
# Prepare startup table (always attempt; fall back gracefully if import fails)
|
# Prepare startup table (always attempt; fall back gracefully if import fails)
|
||||||
startup_table = ResultTable("*********<IGNITIO>*********<NOUSEMPEH>*********<RUGRAPOG>*********<OMEGHAU>*********") if RESULT_TABLE_AVAILABLE else None
|
startup_table = None
|
||||||
|
if RESULT_TABLE_AVAILABLE and ResultTable is not None:
|
||||||
|
startup_table = ResultTable(
|
||||||
|
"*********<IGNITIO>*********<NOUSEMPEH>*********<RUGRAPOG>*********<OMEGHAU>*********"
|
||||||
|
)
|
||||||
if startup_table:
|
if startup_table:
|
||||||
startup_table.set_no_choice(True).set_preserve_order(True)
|
startup_table.set_no_choice(True).set_preserve_order(True)
|
||||||
|
|
||||||
@@ -1173,7 +1178,7 @@ def _create_cmdlet_cli():
|
|||||||
|
|
||||||
api_key = _get_debrid_api_key(config)
|
api_key = _get_debrid_api_key(config)
|
||||||
if not api_key:
|
if not api_key:
|
||||||
_add_startup_check("DISABLED", display, prov, "Not configured")
|
_add_startup_check("DISABLED", display, provider=prov, detail="Not configured")
|
||||||
else:
|
else:
|
||||||
from API.alldebrid import AllDebridClient
|
from API.alldebrid import AllDebridClient
|
||||||
|
|
||||||
@@ -1347,7 +1352,7 @@ def _create_cmdlet_cli():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Silently ignore if config loading fails
|
pass # Silently ignore if config loading fails
|
||||||
|
|
||||||
if PROMPT_TOOLKIT_AVAILABLE and PromptSession is not None and CmdletCompleter is not None:
|
if PROMPT_TOOLKIT_AVAILABLE and PromptSession is not None and CmdletCompleter is not None and Style is not None:
|
||||||
completer = CmdletCompleter()
|
completer = CmdletCompleter()
|
||||||
|
|
||||||
# Define style for syntax highlighting
|
# Define style for syntax highlighting
|
||||||
@@ -1363,7 +1368,7 @@ def _create_cmdlet_cli():
|
|||||||
# Toolbar state for background notifications
|
# Toolbar state for background notifications
|
||||||
class ToolbarState:
|
class ToolbarState:
|
||||||
text = ""
|
text = ""
|
||||||
last_update_time = 0
|
last_update_time: float = 0.0
|
||||||
clear_timer: Optional[threading.Timer] = None
|
clear_timer: Optional[threading.Timer] = None
|
||||||
|
|
||||||
toolbar_state = ToolbarState()
|
toolbar_state = ToolbarState()
|
||||||
@@ -1678,6 +1683,112 @@ def _execute_pipeline(tokens: list):
|
|||||||
# Request terminal-only background updates for this pipeline session
|
# Request terminal-only background updates for this pipeline session
|
||||||
config['_quiet_background_output'] = True
|
config['_quiet_background_output'] = True
|
||||||
|
|
||||||
|
def _maybe_run_class_selector(selected_items: list, *, stage_is_last: bool) -> bool:
|
||||||
|
"""Allow providers/stores to override `@N` selection semantics."""
|
||||||
|
if not stage_is_last:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Gather potential keys from table + selected rows.
|
||||||
|
candidates: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _add(value) -> None:
|
||||||
|
try:
|
||||||
|
text = str(value or '').strip().lower()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not text or text in seen:
|
||||||
|
return
|
||||||
|
seen.add(text)
|
||||||
|
candidates.append(text)
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_table = ctx.get_current_stage_table() or ctx.get_last_result_table()
|
||||||
|
_add(current_table.table if current_table and hasattr(current_table, 'table') else None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for item in selected_items or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
_add(item.get('provider'))
|
||||||
|
_add(item.get('store'))
|
||||||
|
_add(item.get('table'))
|
||||||
|
else:
|
||||||
|
_add(getattr(item, 'provider', None))
|
||||||
|
_add(getattr(item, 'store', None))
|
||||||
|
_add(getattr(item, 'table', None))
|
||||||
|
|
||||||
|
# Provider selector
|
||||||
|
try:
|
||||||
|
from ProviderCore.registry import get_provider as _get_provider
|
||||||
|
except Exception:
|
||||||
|
_get_provider = None
|
||||||
|
|
||||||
|
if _get_provider is not None:
|
||||||
|
for key in candidates:
|
||||||
|
try:
|
||||||
|
provider = _get_provider(key, config)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
handled = bool(provider.selector(selected_items, ctx=ctx, stage_is_last=True))
|
||||||
|
except TypeError:
|
||||||
|
# Backwards-compat: selector(selected_items)
|
||||||
|
handled = bool(provider.selector(selected_items))
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{key} selector failed: {exc}\n")
|
||||||
|
return True
|
||||||
|
if handled:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Store selector
|
||||||
|
store_keys: list[str] = []
|
||||||
|
for item in selected_items or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
v = item.get('store')
|
||||||
|
else:
|
||||||
|
v = getattr(item, 'store', None)
|
||||||
|
try:
|
||||||
|
name = str(v or '').strip()
|
||||||
|
except Exception:
|
||||||
|
name = ''
|
||||||
|
if name:
|
||||||
|
store_keys.append(name)
|
||||||
|
|
||||||
|
if store_keys:
|
||||||
|
try:
|
||||||
|
from Store.registry import Store as _StoreRegistry
|
||||||
|
store_registry = _StoreRegistry(config, suppress_debug=True)
|
||||||
|
try:
|
||||||
|
_backend_names = list(store_registry.list_backends())
|
||||||
|
except Exception:
|
||||||
|
_backend_names = []
|
||||||
|
_backend_by_lower = {str(n).lower(): str(n) for n in _backend_names if str(n).strip()}
|
||||||
|
for name in store_keys:
|
||||||
|
resolved_name = name
|
||||||
|
if not store_registry.is_available(resolved_name):
|
||||||
|
try:
|
||||||
|
resolved_name = _backend_by_lower.get(str(name).lower(), name)
|
||||||
|
except Exception:
|
||||||
|
resolved_name = name
|
||||||
|
if not store_registry.is_available(resolved_name):
|
||||||
|
continue
|
||||||
|
backend = store_registry[resolved_name]
|
||||||
|
selector = getattr(backend, 'selector', None)
|
||||||
|
if selector is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
handled = bool(selector(selected_items, ctx=ctx, stage_is_last=True))
|
||||||
|
except TypeError:
|
||||||
|
handled = bool(selector(selected_items))
|
||||||
|
if handled:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Store init failure should not break normal selection.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
# Check if the first stage has @ selection - if so, apply it before pipeline execution
|
# Check if the first stage has @ selection - if so, apply it before pipeline execution
|
||||||
first_stage_tokens = stages[0] if stages else []
|
first_stage_tokens = stages[0] if stages else []
|
||||||
first_stage_selection_indices = []
|
first_stage_selection_indices = []
|
||||||
@@ -1827,6 +1938,10 @@ def _execute_pipeline(tokens: list):
|
|||||||
try:
|
try:
|
||||||
filtered = [resolved_items[i] for i in first_stage_selection_indices if 0 <= i < len(resolved_items)]
|
filtered = [resolved_items[i] for i in first_stage_selection_indices if 0 <= i < len(resolved_items)]
|
||||||
if filtered:
|
if filtered:
|
||||||
|
# Allow providers/stores to override selection behavior (e.g., Matrix room picker).
|
||||||
|
if _maybe_run_class_selector(filtered, stage_is_last=(not stages)):
|
||||||
|
return
|
||||||
|
|
||||||
# Convert filtered items to PipeObjects for consistent pipeline handling
|
# Convert filtered items to PipeObjects for consistent pipeline handling
|
||||||
from cmdlet._shared import coerce_to_pipe_object
|
from cmdlet._shared import coerce_to_pipe_object
|
||||||
filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered]
|
filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered]
|
||||||
@@ -2011,12 +2126,16 @@ def _execute_pipeline(tokens: list):
|
|||||||
# If not expanding, use as filter
|
# If not expanding, use as filter
|
||||||
if not should_expand_to_command:
|
if not should_expand_to_command:
|
||||||
# This is a selection stage - filter piped results
|
# This is a selection stage - filter piped results
|
||||||
|
# Prefer selecting from the active result context even when nothing is piped.
|
||||||
|
# Some cmdlets present a selectable table and rely on @N afterwards.
|
||||||
if piped_result is None:
|
if piped_result is None:
|
||||||
|
piped_result_list = ctx.get_last_result_items()
|
||||||
|
if not piped_result_list:
|
||||||
print(f"No piped results to select from with {cmd_name}\n")
|
print(f"No piped results to select from with {cmd_name}\n")
|
||||||
pipeline_status = "failed"
|
pipeline_status = "failed"
|
||||||
pipeline_error = f"Selection {cmd_name} without upstream results"
|
pipeline_error = f"Selection {cmd_name} without upstream results"
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
# Normalize piped_result to always be a list for indexing
|
# Normalize piped_result to always be a list for indexing
|
||||||
if isinstance(piped_result, dict) or not isinstance(piped_result, (list, tuple)):
|
if isinstance(piped_result, dict) or not isinstance(piped_result, (list, tuple)):
|
||||||
piped_result_list = [piped_result]
|
piped_result_list = [piped_result]
|
||||||
@@ -2038,12 +2157,29 @@ def _execute_pipeline(tokens: list):
|
|||||||
stage_table = ctx.get_display_table()
|
stage_table = ctx.get_display_table()
|
||||||
if not stage_table:
|
if not stage_table:
|
||||||
stage_table = ctx.get_last_result_table()
|
stage_table = ctx.get_last_result_table()
|
||||||
resolved_list = _resolve_items_for_selection(stage_table, list(piped_result_list))
|
# Prefer selecting from the displayed table's items if available.
|
||||||
_debug_selection("pipeline-stage", selection_indices, stage_table, piped_result_list, resolved_list)
|
# This matters when a cmdlet shows a selectable overlay table but does not emit
|
||||||
|
# items downstream (e.g., add-file -provider matrix shows rooms, but the piped
|
||||||
|
# value is still the original file).
|
||||||
|
selection_base = list(piped_result_list)
|
||||||
|
try:
|
||||||
|
table_rows = len(stage_table.rows) if stage_table and hasattr(stage_table, 'rows') and stage_table.rows else None
|
||||||
|
last_items = ctx.get_last_result_items()
|
||||||
|
if last_items and table_rows is not None and len(last_items) == table_rows:
|
||||||
|
selection_base = list(last_items)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
resolved_list = _resolve_items_for_selection(stage_table, selection_base)
|
||||||
|
_debug_selection("pipeline-stage", selection_indices, stage_table, selection_base, resolved_list)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
filtered = [resolved_list[i] for i in selection_indices if 0 <= i < len(resolved_list)]
|
filtered = [resolved_list[i] for i in selection_indices if 0 <= i < len(resolved_list)]
|
||||||
if filtered:
|
if filtered:
|
||||||
|
# Allow providers/stores to override selection behavior (e.g., Matrix room picker).
|
||||||
|
if _maybe_run_class_selector(filtered, stage_is_last=(stage_index + 1 >= len(stages))):
|
||||||
|
return
|
||||||
|
|
||||||
# Convert filtered items to PipeObjects for consistent pipeline handling
|
# Convert filtered items to PipeObjects for consistent pipeline handling
|
||||||
from cmdlet._shared import coerce_to_pipe_object
|
from cmdlet._shared import coerce_to_pipe_object
|
||||||
filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered]
|
filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered]
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Medeia-Macina package - Media management system."""
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Entry point wrapper for Medeia-Macina CLI."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add the parent directory to sys.path so we can import CLI
|
|
||||||
root_dir = Path(__file__).parent.parent
|
|
||||||
if str(root_dir) not in sys.path:
|
|
||||||
sys.path.insert(0, str(root_dir))
|
|
||||||
|
|
||||||
from CLI import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
BIN
Log/medeia_macina/telegram.session
Normal file
BIN
Log/medeia_macina/telegram.session
Normal file
Binary file not shown.
292
MPV/LUA/main.lua
292
MPV/LUA/main.lua
@@ -268,6 +268,9 @@ local _cached_store_names = {}
|
|||||||
local _store_cache_loaded = false
|
local _store_cache_loaded = false
|
||||||
|
|
||||||
local _pipeline_helper_started = false
|
local _pipeline_helper_started = false
|
||||||
|
local _last_ipc_error = ''
|
||||||
|
local _last_ipc_last_req_json = ''
|
||||||
|
local _last_ipc_last_resp_json = ''
|
||||||
|
|
||||||
local function _is_pipeline_helper_ready()
|
local function _is_pipeline_helper_ready()
|
||||||
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||||
@@ -431,8 +434,10 @@ end
|
|||||||
local ensure_pipeline_helper_running
|
local ensure_pipeline_helper_running
|
||||||
|
|
||||||
local function _run_helper_request_response(req, timeout_seconds)
|
local function _run_helper_request_response(req, timeout_seconds)
|
||||||
|
_last_ipc_error = ''
|
||||||
if not ensure_pipeline_helper_running() then
|
if not ensure_pipeline_helper_running() then
|
||||||
_lua_log('ipc: helper not running; cannot execute request')
|
_lua_log('ipc: helper not running; cannot execute request')
|
||||||
|
_last_ipc_error = 'helper not running'
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -445,7 +450,9 @@ local function _run_helper_request_response(req, timeout_seconds)
|
|||||||
mp.wait_event(0.05)
|
mp.wait_event(0.05)
|
||||||
end
|
end
|
||||||
if not _is_pipeline_helper_ready() then
|
if not _is_pipeline_helper_ready() then
|
||||||
_lua_log('ipc: helper not ready; ready=' .. tostring(mp.get_property_native(PIPELINE_READY_PROP)))
|
local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP))
|
||||||
|
_lua_log('ipc: helper not ready; ready=' .. rv)
|
||||||
|
_last_ipc_error = 'helper not ready (ready=' .. rv .. ')'
|
||||||
_pipeline_helper_started = false
|
_pipeline_helper_started = false
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -471,13 +478,21 @@ local function _run_helper_request_response(req, timeout_seconds)
|
|||||||
end
|
end
|
||||||
_lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label)
|
_lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label)
|
||||||
|
|
||||||
|
local req_json = utils.format_json(req)
|
||||||
|
_last_ipc_last_req_json = req_json
|
||||||
mp.set_property(PIPELINE_RESP_PROP, '')
|
mp.set_property(PIPELINE_RESP_PROP, '')
|
||||||
mp.set_property(PIPELINE_REQ_PROP, utils.format_json(req))
|
mp.set_property(PIPELINE_REQ_PROP, req_json)
|
||||||
|
-- Read-back for debugging: confirms MPV accepted the property write.
|
||||||
|
local echoed = mp.get_property(PIPELINE_REQ_PROP) or ''
|
||||||
|
if echoed == '' then
|
||||||
|
_lua_log('ipc: WARNING request property echoed empty after set')
|
||||||
|
end
|
||||||
|
|
||||||
local deadline = mp.get_time() + (timeout_seconds or 5)
|
local deadline = mp.get_time() + (timeout_seconds or 5)
|
||||||
while mp.get_time() < deadline do
|
while mp.get_time() < deadline do
|
||||||
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
||||||
if resp_json and resp_json ~= '' then
|
if resp_json and resp_json ~= '' then
|
||||||
|
_last_ipc_last_resp_json = resp_json
|
||||||
local ok, resp = pcall(utils.parse_json, resp_json)
|
local ok, resp = pcall(utils.parse_json, resp_json)
|
||||||
if ok and resp and resp.id == id then
|
if ok and resp and resp.id == id then
|
||||||
_lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
|
_lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
|
||||||
@@ -488,6 +503,7 @@ local function _run_helper_request_response(req, timeout_seconds)
|
|||||||
end
|
end
|
||||||
|
|
||||||
_lua_log('ipc: timeout waiting response; ' .. label)
|
_lua_log('ipc: timeout waiting response; ' .. label)
|
||||||
|
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
|
||||||
_pipeline_helper_started = false
|
_pipeline_helper_started = false
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -593,12 +609,39 @@ end)
|
|||||||
local _pending_download = nil
|
local _pending_download = nil
|
||||||
local _pending_format_change = nil
|
local _pending_format_change = nil
|
||||||
|
|
||||||
|
-- Per-file state (class-like) for format caching.
|
||||||
|
local FileState = {}
|
||||||
|
FileState.__index = FileState
|
||||||
|
|
||||||
|
function FileState.new()
|
||||||
|
return setmetatable({
|
||||||
|
url = nil,
|
||||||
|
formats = nil,
|
||||||
|
formats_table = nil, -- back-compat alias
|
||||||
|
}, FileState)
|
||||||
|
end
|
||||||
|
|
||||||
|
function FileState:has_formats()
|
||||||
|
return type(self.formats) == 'table'
|
||||||
|
and type(self.formats.rows) == 'table'
|
||||||
|
and #self.formats.rows > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function FileState:set_formats(url, tbl)
|
||||||
|
self.url = url
|
||||||
|
self.formats = tbl
|
||||||
|
self.formats_table = tbl
|
||||||
|
end
|
||||||
|
|
||||||
|
M.file = M.file or FileState.new()
|
||||||
|
|
||||||
-- Cache yt-dlp format lists per URL so Change Format is instant.
|
-- Cache yt-dlp format lists per URL so Change Format is instant.
|
||||||
M.file = M.file or {}
|
|
||||||
M.file.formats_table = nil
|
|
||||||
M.file.url = nil
|
|
||||||
local _formats_cache = {}
|
local _formats_cache = {}
|
||||||
local _formats_inflight = {}
|
local _formats_inflight = {}
|
||||||
|
local _formats_waiters = {}
|
||||||
|
|
||||||
|
local _ipc_async_busy = false
|
||||||
|
local _ipc_async_queue = {}
|
||||||
|
|
||||||
local function _is_http_url(u)
|
local function _is_http_url(u)
|
||||||
if type(u) ~= 'string' then
|
if type(u) ~= 'string' then
|
||||||
@@ -615,8 +658,13 @@ local function _cache_formats_for_url(url, tbl)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
_formats_cache[url] = { table = tbl, ts = mp.get_time() }
|
_formats_cache[url] = { table = tbl, ts = mp.get_time() }
|
||||||
|
if type(M.file) == 'table' and M.file.set_formats then
|
||||||
|
M.file:set_formats(url, tbl)
|
||||||
|
else
|
||||||
M.file.url = url
|
M.file.url = url
|
||||||
|
M.file.formats = tbl
|
||||||
M.file.formats_table = tbl
|
M.file.formats_table = tbl
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _get_cached_formats_table(url)
|
local function _get_cached_formats_table(url)
|
||||||
@@ -630,42 +678,175 @@ local function _get_cached_formats_table(url)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _prefetch_formats_for_url(url)
|
local function _run_helper_request_async(req, timeout_seconds, cb)
|
||||||
url = tostring(url or '')
|
cb = cb or function() end
|
||||||
|
|
||||||
|
if _ipc_async_busy then
|
||||||
|
_ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
_ipc_async_busy = true
|
||||||
|
|
||||||
|
local function done(resp, err)
|
||||||
|
_ipc_async_busy = false
|
||||||
|
cb(resp, err)
|
||||||
|
|
||||||
|
if #_ipc_async_queue > 0 then
|
||||||
|
local next_job = table.remove(_ipc_async_queue, 1)
|
||||||
|
-- Schedule next job slightly later to let mpv deliver any pending events.
|
||||||
|
mp.add_timeout(0.01, function()
|
||||||
|
_run_helper_request_async(next_job.req, next_job.timeout, next_job.cb)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(req) ~= 'table' then
|
||||||
|
done(nil, 'invalid request')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ensure_mpv_ipc_server()
|
||||||
|
if not ensure_pipeline_helper_running() then
|
||||||
|
done(nil, 'helper not running')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Assign id.
|
||||||
|
local id = tostring(req.id or '')
|
||||||
|
if id == '' then
|
||||||
|
id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
|
||||||
|
req.id = id
|
||||||
|
end
|
||||||
|
|
||||||
|
local label = ''
|
||||||
|
if req.op then
|
||||||
|
label = 'op=' .. tostring(req.op)
|
||||||
|
elseif req.pipeline then
|
||||||
|
label = 'cmd=' .. tostring(req.pipeline)
|
||||||
|
else
|
||||||
|
label = '(unknown)'
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wait for helper READY without blocking the UI.
|
||||||
|
local ready_deadline = mp.get_time() + 3.0
|
||||||
|
local ready_timer
|
||||||
|
ready_timer = mp.add_periodic_timer(0.05, function()
|
||||||
|
if _is_pipeline_helper_ready() then
|
||||||
|
ready_timer:kill()
|
||||||
|
|
||||||
|
_lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label)
|
||||||
|
local req_json = utils.format_json(req)
|
||||||
|
_last_ipc_last_req_json = req_json
|
||||||
|
|
||||||
|
mp.set_property(PIPELINE_RESP_PROP, '')
|
||||||
|
mp.set_property(PIPELINE_REQ_PROP, req_json)
|
||||||
|
|
||||||
|
local deadline = mp.get_time() + (timeout_seconds or 5)
|
||||||
|
local poll_timer
|
||||||
|
poll_timer = mp.add_periodic_timer(0.05, function()
|
||||||
|
if mp.get_time() >= deadline then
|
||||||
|
poll_timer:kill()
|
||||||
|
done(nil, 'timeout waiting response (' .. label .. ')')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
||||||
|
if resp_json and resp_json ~= '' then
|
||||||
|
_last_ipc_last_resp_json = resp_json
|
||||||
|
local ok, resp = pcall(utils.parse_json, resp_json)
|
||||||
|
if ok and resp and resp.id == id then
|
||||||
|
poll_timer:kill()
|
||||||
|
_lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
|
||||||
|
done(resp, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if mp.get_time() >= ready_deadline then
|
||||||
|
ready_timer:kill()
|
||||||
|
done(nil, 'helper not ready')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function FileState:fetch_formats(cb)
|
||||||
|
local url = tostring(self.url or '')
|
||||||
if url == '' or not _is_http_url(url) then
|
if url == '' or not _is_http_url(url) then
|
||||||
|
if cb then cb(false, 'not a url') end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Only applies to plain URLs (not store hash URLs).
|
-- Only applies to plain URLs (not store hash URLs).
|
||||||
if _extract_store_hash(url) then
|
if _extract_store_hash(url) then
|
||||||
|
if cb then cb(false, 'store-hash url') end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if _get_cached_formats_table(url) then
|
-- Cache hit.
|
||||||
|
local cached = _get_cached_formats_table(url)
|
||||||
|
if type(cached) == 'table' then
|
||||||
|
self:set_formats(url, cached)
|
||||||
|
if cb then cb(true, nil) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- In-flight: register waiter.
|
||||||
if _formats_inflight[url] then
|
if _formats_inflight[url] then
|
||||||
|
_formats_waiters[url] = _formats_waiters[url] or {}
|
||||||
|
if cb then table.insert(_formats_waiters[url], cb) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
_formats_inflight[url] = true
|
_formats_inflight[url] = true
|
||||||
|
_formats_waiters[url] = _formats_waiters[url] or {}
|
||||||
|
if cb then table.insert(_formats_waiters[url], cb) end
|
||||||
|
|
||||||
mp.add_timeout(0.01, function()
|
-- Async request so the UI never blocks.
|
||||||
if _get_cached_formats_table(url) then
|
_run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err)
|
||||||
_formats_inflight[url] = nil
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
ensure_mpv_ipc_server()
|
|
||||||
local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 20)
|
|
||||||
_formats_inflight[url] = nil
|
_formats_inflight[url] = nil
|
||||||
|
|
||||||
|
local ok = false
|
||||||
|
local reason = err
|
||||||
if resp and resp.success and type(resp.table) == 'table' then
|
if resp and resp.success and type(resp.table) == 'table' then
|
||||||
|
ok = true
|
||||||
|
reason = nil
|
||||||
|
self:set_formats(url, resp.table)
|
||||||
_cache_formats_for_url(url, resp.table)
|
_cache_formats_for_url(url, resp.table)
|
||||||
_lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
|
_lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
|
||||||
|
else
|
||||||
|
if type(resp) == 'table' then
|
||||||
|
if resp.error and tostring(resp.error) ~= '' then
|
||||||
|
reason = tostring(resp.error)
|
||||||
|
elseif resp.stderr and tostring(resp.stderr) ~= '' then
|
||||||
|
reason = tostring(resp.stderr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local waiters = _formats_waiters[url] or {}
|
||||||
|
_formats_waiters[url] = nil
|
||||||
|
for _, fn in ipairs(waiters) do
|
||||||
|
pcall(fn, ok, reason)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function _prefetch_formats_for_url(url)
|
||||||
|
url = tostring(url or '')
|
||||||
|
if url == '' or not _is_http_url(url) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if type(M.file) == 'table' then
|
||||||
|
M.file.url = url
|
||||||
|
if M.file.fetch_formats then
|
||||||
|
M.file:fetch_formats(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function _open_loading_formats_menu(title)
|
local function _open_loading_formats_menu(title)
|
||||||
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, title or 'Pick format', {
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, title or 'Pick format', {
|
||||||
{
|
{
|
||||||
@@ -676,6 +857,34 @@ local function _open_loading_formats_menu(title)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function _debug_dump_formatted_formats(url, tbl, items)
|
||||||
|
local row_count = 0
|
||||||
|
if type(tbl) == 'table' and type(tbl.rows) == 'table' then
|
||||||
|
row_count = #tbl.rows
|
||||||
|
end
|
||||||
|
local item_count = 0
|
||||||
|
if type(items) == 'table' then
|
||||||
|
item_count = #items
|
||||||
|
end
|
||||||
|
|
||||||
|
_lua_log('formats-dump: url=' .. tostring(url or '') .. ' rows=' .. tostring(row_count) .. ' menu_items=' .. tostring(item_count))
|
||||||
|
|
||||||
|
-- Dump the formatted picker items (first 30) so we can confirm the
|
||||||
|
-- list is being built and looks sane.
|
||||||
|
if type(items) == 'table' then
|
||||||
|
local limit = 30
|
||||||
|
for i = 1, math.min(#items, limit) do
|
||||||
|
local it = items[i] or {}
|
||||||
|
local title = tostring(it.title or '')
|
||||||
|
local hint = tostring(it.hint or '')
|
||||||
|
_lua_log('formats-item[' .. tostring(i) .. ']: ' .. title .. (hint ~= '' and (' | ' .. hint) or ''))
|
||||||
|
end
|
||||||
|
if #items > limit then
|
||||||
|
_lua_log('formats-dump: (truncated; total=' .. tostring(#items) .. ')')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function _current_ytdl_format_string()
|
local function _current_ytdl_format_string()
|
||||||
-- Preferred: mpv exposes the active ytdl format string.
|
-- Preferred: mpv exposes the active ytdl format string.
|
||||||
local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
|
local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
|
||||||
@@ -857,8 +1066,18 @@ mp.register_script_message('medios-change-format-current', function()
|
|||||||
|
|
||||||
local url = tostring(target)
|
local url = tostring(target)
|
||||||
|
|
||||||
|
-- Ensure file state is tracking the current URL.
|
||||||
|
if type(M.file) == 'table' then
|
||||||
|
M.file.url = url
|
||||||
|
end
|
||||||
|
|
||||||
-- If formats were already prefetched for this URL, open instantly.
|
-- If formats were already prefetched for this URL, open instantly.
|
||||||
local cached_tbl = _get_cached_formats_table(url)
|
local cached_tbl = nil
|
||||||
|
if type(M.file) == 'table' and type(M.file.formats) == 'table' then
|
||||||
|
cached_tbl = M.file.formats
|
||||||
|
else
|
||||||
|
cached_tbl = _get_cached_formats_table(url)
|
||||||
|
end
|
||||||
if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then
|
if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then
|
||||||
_pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl }
|
_pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl }
|
||||||
|
|
||||||
@@ -890,6 +1109,7 @@ mp.register_script_message('medios-change-format-current', function()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
_debug_dump_formatted_formats(url, cached_tbl, items)
|
||||||
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -898,41 +1118,32 @@ mp.register_script_message('medios-change-format-current', function()
|
|||||||
_pending_format_change = { url = url, token = token }
|
_pending_format_change = { url = url, token = token }
|
||||||
_open_loading_formats_menu('Change format')
|
_open_loading_formats_menu('Change format')
|
||||||
|
|
||||||
mp.add_timeout(0.05, function()
|
-- Non-blocking: ask the per-file state to fetch formats in the background.
|
||||||
|
if type(M.file) == 'table' and M.file.fetch_formats then
|
||||||
|
_lua_log('change-format: formats not cached yet; fetching in background')
|
||||||
|
M.file:fetch_formats(function(ok, err)
|
||||||
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
|
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if not ok then
|
||||||
ensure_mpv_ipc_server()
|
local msg2 = tostring(err or '')
|
||||||
_lua_log('change-format: requesting formats via helper op for url')
|
if msg2 == '' then
|
||||||
|
msg2 = 'unknown'
|
||||||
local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 30)
|
|
||||||
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
_lua_log('change-format: formats failed: ' .. msg2)
|
||||||
if not resp or not resp.success or type(resp.table) ~= 'table' then
|
mp.osd_message('Failed to load format list: ' .. msg2, 7)
|
||||||
local err = ''
|
|
||||||
if type(resp) == 'table' then
|
|
||||||
if resp.error and tostring(resp.error) ~= '' then err = tostring(resp.error) end
|
|
||||||
if resp.stderr and tostring(resp.stderr) ~= '' then
|
|
||||||
err = (err ~= '' and (err .. ' | ') or '') .. tostring(resp.stderr)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
_lua_log('change-format: formats failed: ' .. (err ~= '' and err or '(no details)'))
|
|
||||||
mp.osd_message('Failed to load format list', 5)
|
|
||||||
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', {
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', {
|
||||||
{
|
{
|
||||||
title = 'Failed to load format list',
|
title = 'Failed to load format list',
|
||||||
hint = 'Check logs (medeia-mpv-lua.log / medeia-mpv-helper.log)',
|
hint = msg2,
|
||||||
value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
|
value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local tbl = resp.table
|
local tbl = (type(M.file.formats) == 'table') and M.file.formats or _get_cached_formats_table(url)
|
||||||
if type(tbl.rows) ~= 'table' or #tbl.rows == 0 then
|
if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or #tbl.rows == 0 then
|
||||||
mp.osd_message('No formats available', 4)
|
mp.osd_message('No formats available', 4)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -966,9 +1177,10 @@ mp.register_script_message('medios-change-format-current', function()
|
|||||||
end
|
end
|
||||||
|
|
||||||
_pending_format_change.formats_table = tbl
|
_pending_format_change.formats_table = tbl
|
||||||
_cache_formats_for_url(url, tbl)
|
_debug_dump_formatted_formats(url, tbl, items)
|
||||||
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
||||||
end)
|
end)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.
|
-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.
|
||||||
|
|||||||
@@ -575,6 +575,46 @@ class MPV:
|
|||||||
debug("Starting MPV")
|
debug("Starting MPV")
|
||||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
|
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
|
||||||
|
|
||||||
|
# Start the persistent pipeline helper eagerly so MPV Lua can issue
|
||||||
|
# non-blocking requests (e.g., format list prefetch) without needing
|
||||||
|
# to spawn the helper on-demand from inside mpv.
|
||||||
|
try:
|
||||||
|
helper_path = (repo_root / "MPV" / "pipeline_helper.py").resolve()
|
||||||
|
if helper_path.exists():
|
||||||
|
py = sys.executable or "python"
|
||||||
|
helper_cmd = [
|
||||||
|
py,
|
||||||
|
str(helper_path),
|
||||||
|
"--ipc",
|
||||||
|
str(self.ipc_path),
|
||||||
|
"--timeout",
|
||||||
|
"30",
|
||||||
|
]
|
||||||
|
|
||||||
|
helper_kwargs: Dict[str, Any] = {}
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
flags = 0
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x00000008
|
||||||
|
try:
|
||||||
|
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
|
||||||
|
except Exception:
|
||||||
|
flags |= 0x08000000
|
||||||
|
helper_kwargs["creationflags"] = flags
|
||||||
|
helper_kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
|
||||||
|
|
||||||
|
subprocess.Popen(
|
||||||
|
helper_cmd,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
**helper_kwargs,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_ipc_pipe_path() -> str:
|
def get_ipc_pipe_path() -> str:
|
||||||
"""Get the fixed IPC pipe/socket path for persistent MPV connection.
|
"""Get the fixed IPC pipe/socket path for persistent MPV connection.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -259,6 +260,53 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
|
|||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
|
# Debug: dump a short summary of the format list to the helper log.
|
||||||
|
try:
|
||||||
|
formats_any = info.get("formats") if isinstance(info, dict) else None
|
||||||
|
count = len(formats_any) if isinstance(formats_any, list) else 0
|
||||||
|
_append_helper_log(f"[ytdlp-formats] extracted formats count={count} url={url}")
|
||||||
|
|
||||||
|
if isinstance(formats_any, list) and formats_any:
|
||||||
|
limit = 60
|
||||||
|
for i, f in enumerate(formats_any[:limit], start=1):
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
fid = str(f.get("format_id") or "")
|
||||||
|
ext = str(f.get("ext") or "")
|
||||||
|
note = f.get("format_note") or f.get("format") or ""
|
||||||
|
vcodec = str(f.get("vcodec") or "")
|
||||||
|
acodec = str(f.get("acodec") or "")
|
||||||
|
size = f.get("filesize") or f.get("filesize_approx")
|
||||||
|
res = str(f.get("resolution") or "")
|
||||||
|
if not res:
|
||||||
|
try:
|
||||||
|
w = f.get("width")
|
||||||
|
h = f.get("height")
|
||||||
|
if w and h:
|
||||||
|
res = f"{int(w)}x{int(h)}"
|
||||||
|
elif h:
|
||||||
|
res = f"{int(h)}p"
|
||||||
|
except Exception:
|
||||||
|
res = ""
|
||||||
|
_append_helper_log(
|
||||||
|
f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}"
|
||||||
|
)
|
||||||
|
if count > limit:
|
||||||
|
_append_helper_log(f"[ytdlp-formats] (truncated; total={count})")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Optional: dump the full extracted JSON for inspection.
|
||||||
|
try:
|
||||||
|
dump = os.environ.get("MEDEIA_MPV_YTDLP_DUMP", "").strip()
|
||||||
|
if dump and dump != "0" and isinstance(info, dict):
|
||||||
|
h = hashlib.sha1(url.encode("utf-8", errors="replace")).hexdigest()[:10]
|
||||||
|
out_path = _repo_root() / "Log" / f"ytdlp-probe-{h}.json"
|
||||||
|
out_path.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8", errors="replace")
|
||||||
|
_append_helper_log(f"[ytdlp-formats] wrote probe json: {out_path}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not isinstance(info, dict):
|
if not isinstance(info, dict):
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -577,7 +625,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
# Mirror mpv's own log messages into our helper log file so debugging does
|
# Mirror mpv's own log messages into our helper log file so debugging does
|
||||||
# not depend on the mpv on-screen console or mpv's log-file.
|
# not depend on the mpv on-screen console or mpv's log-file.
|
||||||
try:
|
try:
|
||||||
level = "debug" if debug_enabled else "warn"
|
# IMPORTANT: mpv debug logs can be extremely chatty (especially ytdl_hook)
|
||||||
|
# and can starve request handling. Default to warn unless explicitly overridden.
|
||||||
|
level = os.environ.get("MEDEIA_MPV_HELPER_MPVLOG", "").strip() or "warn"
|
||||||
client.send_command_no_wait(["request_log_messages", level])
|
client.send_command_no_wait(["request_log_messages", level])
|
||||||
_append_helper_log(f"[helper] requested mpv log messages level={level}")
|
_append_helper_log(f"[helper] requested mpv log messages level={level}")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -666,8 +716,17 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|||||||
if msg.get("id") != OBS_ID_REQUEST:
|
if msg.get("id") != OBS_ID_REQUEST:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
req = _parse_request(msg.get("data"))
|
raw = msg.get("data")
|
||||||
|
req = _parse_request(raw)
|
||||||
if not req:
|
if not req:
|
||||||
|
try:
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
snippet = raw.strip().replace("\r", "").replace("\n", " ")
|
||||||
|
if len(snippet) > 220:
|
||||||
|
snippet = snippet[:220] + "…"
|
||||||
|
_append_helper_log(f"[request-raw] could not parse request json: {snippet}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
req_id = str(req.get("id") or "")
|
req_id = str(req.get("id") or "")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
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 ProviderCore.download import sanitize_filename
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AllDebrid(SearchProvider):
|
class AllDebrid(Provider):
|
||||||
"""Search provider for AllDebrid account content.
|
"""Search provider for AllDebrid account content.
|
||||||
|
|
||||||
This provider lists and searches the files/magnets already present in the
|
This provider lists and searches the files/magnets already present in the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional
|
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 SYS.logger import log, debug
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,7 +12,7 @@ except ImportError: # pragma: no cover
|
|||||||
sync_playwright = None
|
sync_playwright = None
|
||||||
|
|
||||||
|
|
||||||
class Bandcamp(SearchProvider):
|
class Bandcamp(Provider):
|
||||||
"""Search provider for Bandcamp."""
|
"""Search provider for Bandcamp."""
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import urljoin, urlparse, unquote
|
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 ProviderCore.download import sanitize_filename
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
from models import ProgressBar
|
from models import ProgressBar
|
||||||
@@ -22,7 +22,7 @@ except ImportError:
|
|||||||
lxml_html = None
|
lxml_html = None
|
||||||
|
|
||||||
|
|
||||||
class Libgen(SearchProvider):
|
class Libgen(Provider):
|
||||||
"""Search provider for Library Genesis books."""
|
"""Search provider for Library Genesis books."""
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ProviderCore.base import FileProvider
|
from ProviderCore.base import Provider
|
||||||
|
|
||||||
|
|
||||||
_MATRIX_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {}
|
_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)
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
class Matrix(FileProvider):
|
class Matrix(Provider):
|
||||||
"""File provider for Matrix (Element) chat rooms."""
|
"""File provider for Matrix (Element) chat rooms."""
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
@@ -208,3 +208,82 @@ class Matrix(FileProvider):
|
|||||||
if not room_id:
|
if not room_id:
|
||||||
raise Exception("Matrix room_id missing")
|
raise Exception("Matrix room_id missing")
|
||||||
return self.upload_to_room(file_path, str(room_id))
|
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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from API.HTTP import HTTPClient
|
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 ProviderCore.download import download_file, sanitize_filename
|
||||||
from cli_syntax import get_field, get_free_text, parse_query
|
from cli_syntax import get_field, get_free_text, parse_query
|
||||||
from SYS.logger import debug, log
|
from SYS.logger import debug, log
|
||||||
@@ -183,7 +183,7 @@ def _resolve_archive_id(session: requests.Session, edition_id: str, ia_candidate
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class OpenLibrary(SearchProvider):
|
class OpenLibrary(Provider):
|
||||||
"""Search provider for OpenLibrary books + Archive.org direct/borrow download."""
|
"""Search provider for OpenLibrary books + Archive.org direct/borrow download."""
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
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 SYS.logger import log, debug
|
||||||
from models import ProgressBar
|
from models import ProgressBar
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ def _suppress_aioslsk_noise() -> Any:
|
|||||||
sys.stdout, sys.stderr = old_out, old_err
|
sys.stdout, sys.stderr = old_out, old_err
|
||||||
|
|
||||||
|
|
||||||
class Soulseek(SearchProvider):
|
class Soulseek(Provider):
|
||||||
"""Search provider for Soulseek P2P network."""
|
"""Search provider for Soulseek P2P network."""
|
||||||
|
|
||||||
MUSIC_EXTENSIONS = {
|
MUSIC_EXTENSIONS = {
|
||||||
|
|||||||
284
Provider/telegram.py
Normal file
284
Provider/telegram.py
Normal 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
|
||||||
@@ -6,11 +6,11 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ProviderCore.base import SearchProvider, SearchResult
|
from ProviderCore.base import Provider, SearchResult
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|
||||||
|
|
||||||
class YouTube(SearchProvider):
|
class YouTube(Provider):
|
||||||
"""Search provider for YouTube using yt-dlp."""
|
"""Search provider for YouTube using yt-dlp."""
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ProviderCore.base import FileProvider
|
from ProviderCore.base import Provider
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|
||||||
|
|
||||||
class ZeroXZero(FileProvider):
|
class ZeroXZero(Provider):
|
||||||
"""File provider for 0x0.st."""
|
"""File provider for 0x0.st."""
|
||||||
|
|
||||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||||
|
|||||||
@@ -39,14 +39,26 @@ class SearchResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SearchProvider(ABC):
|
class Provider(ABC):
|
||||||
"""Base class for search providers."""
|
"""Unified provider base class.
|
||||||
|
|
||||||
|
This replaces the older split between "search providers" and "file providers".
|
||||||
|
Concrete providers may implement any subset of:
|
||||||
|
- search(query, ...)
|
||||||
|
- download(result, output_dir)
|
||||||
|
- upload(file_path, ...)
|
||||||
|
- login(...)
|
||||||
|
- validate()
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.name = self.__class__.__name__.lower()
|
self.name = self.__class__.__name__.lower()
|
||||||
|
|
||||||
@abstractmethod
|
# Standard lifecycle/auth hook.
|
||||||
|
def login(self, **_kwargs: Any) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
def search(
|
def search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -55,30 +67,46 @@ class SearchProvider(ABC):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> List[SearchResult]:
|
) -> List[SearchResult]:
|
||||||
"""Search for items matching the query."""
|
"""Search for items matching the query."""
|
||||||
|
raise NotImplementedError(f"Provider '{self.name}' does not support search")
|
||||||
|
|
||||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||||
"""Download an item from a search result."""
|
"""Download an item from a search result."""
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def upload(self, file_path: str, **kwargs: Any) -> str:
|
||||||
|
"""Upload a file and return a URL or identifier."""
|
||||||
|
raise NotImplementedError(f"Provider '{self.name}' does not support upload")
|
||||||
|
|
||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
"""Check if provider is available and properly configured."""
|
"""Check if provider is available and properly configured."""
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def selector(self, selected_items: List[Any], *, ctx: Any, stage_is_last: bool = True, **_kwargs: Any) -> bool:
|
||||||
|
"""Optional hook for handling `@N` selection semantics.
|
||||||
|
|
||||||
class FileProvider(ABC):
|
The CLI can delegate selection behavior to a provider/store instead of
|
||||||
"""Base class for file upload providers."""
|
applying the default selection filtering.
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
Return True if the selection was handled and default behavior should be skipped.
|
||||||
self.config = config or {}
|
"""
|
||||||
self.name = self.__class__.__name__.lower()
|
|
||||||
|
|
||||||
@abstractmethod
|
_ = selected_items
|
||||||
def upload(self, file_path: str, **kwargs: Any) -> str:
|
_ = ctx
|
||||||
"""Upload a file and return the URL."""
|
_ = stage_is_last
|
||||||
|
return False
|
||||||
|
|
||||||
def validate(self) -> bool:
|
|
||||||
"""Check if provider is available/configured."""
|
|
||||||
|
|
||||||
return True
|
class SearchProvider(Provider):
|
||||||
|
"""Compatibility alias for older code.
|
||||||
|
|
||||||
|
Prefer inheriting from Provider directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FileProvider(Provider):
|
||||||
|
"""Compatibility alias for older code.
|
||||||
|
|
||||||
|
Prefer inheriting from Provider directly.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -11,33 +11,47 @@ import sys
|
|||||||
|
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|
||||||
from ProviderCore.base import FileProvider, SearchProvider, SearchResult
|
from ProviderCore.base import Provider, SearchProvider, FileProvider, SearchResult
|
||||||
from Provider.alldebrid import AllDebrid
|
from Provider.alldebrid import AllDebrid
|
||||||
from Provider.bandcamp import Bandcamp
|
from Provider.bandcamp import Bandcamp
|
||||||
from Provider.libgen import Libgen
|
from Provider.libgen import Libgen
|
||||||
from Provider.matrix import Matrix
|
from Provider.matrix import Matrix
|
||||||
from Provider.openlibrary import OpenLibrary
|
from Provider.openlibrary import OpenLibrary
|
||||||
from Provider.soulseek import Soulseek, download_soulseek_file
|
from Provider.soulseek import Soulseek, download_soulseek_file
|
||||||
|
from Provider.telegram import Telegram
|
||||||
from Provider.youtube import YouTube
|
from Provider.youtube import YouTube
|
||||||
from Provider.zeroxzero import ZeroXZero
|
from Provider.zeroxzero import ZeroXZero
|
||||||
|
|
||||||
|
|
||||||
_SEARCH_PROVIDERS: Dict[str, Type[SearchProvider]] = {
|
_PROVIDERS: Dict[str, Type[Provider]] = {
|
||||||
|
# Search-capable providers
|
||||||
"alldebrid": AllDebrid,
|
"alldebrid": AllDebrid,
|
||||||
"libgen": Libgen,
|
"libgen": Libgen,
|
||||||
"openlibrary": OpenLibrary,
|
"openlibrary": OpenLibrary,
|
||||||
"soulseek": Soulseek,
|
"soulseek": Soulseek,
|
||||||
"bandcamp": Bandcamp,
|
"bandcamp": Bandcamp,
|
||||||
"youtube": YouTube,
|
"youtube": YouTube,
|
||||||
|
"telegram": Telegram,
|
||||||
|
# Upload-capable providers
|
||||||
|
"0x0": ZeroXZero,
|
||||||
|
"matrix": Matrix,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_search_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
|
def _supports_search(provider: Provider) -> bool:
|
||||||
"""Get a search provider by name."""
|
return provider.__class__.search is not Provider.search
|
||||||
|
|
||||||
provider_class = _SEARCH_PROVIDERS.get((name or "").lower())
|
|
||||||
|
def _supports_upload(provider: Provider) -> bool:
|
||||||
|
return provider.__class__.upload is not Provider.upload
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
|
||||||
|
"""Get a provider by name (unified registry)."""
|
||||||
|
|
||||||
|
provider_class = _PROVIDERS.get((name or "").lower())
|
||||||
if provider_class is None:
|
if provider_class is None:
|
||||||
log(f"[provider] Unknown search provider: {name}", file=sys.stderr)
|
log(f"[provider] Unknown provider: {name}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -51,11 +65,11 @@ def get_search_provider(name: str, config: Optional[Dict[str, Any]] = None) -> O
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
def list_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
||||||
"""List all search providers and their availability."""
|
"""List all providers and their availability."""
|
||||||
|
|
||||||
availability: Dict[str, bool] = {}
|
availability: Dict[str, bool] = {}
|
||||||
for name, provider_class in _SEARCH_PROVIDERS.items():
|
for name, provider_class in _PROVIDERS.items():
|
||||||
try:
|
try:
|
||||||
provider = provider_class(config)
|
provider = provider_class(config)
|
||||||
availability[name] = provider.validate()
|
availability[name] = provider.validate()
|
||||||
@@ -64,39 +78,51 @@ def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|||||||
return availability
|
return availability
|
||||||
|
|
||||||
|
|
||||||
_FILE_PROVIDERS: Dict[str, Type[FileProvider]] = {
|
def get_search_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
|
||||||
"0x0": ZeroXZero,
|
"""Get a search-capable provider by name (compat API)."""
|
||||||
"matrix": Matrix,
|
|
||||||
}
|
provider = get_provider(name, config)
|
||||||
|
if provider is None:
|
||||||
|
return None
|
||||||
|
if not _supports_search(provider):
|
||||||
|
log(f"[provider] Provider '{name}' does not support search", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
return provider # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
||||||
|
"""List all search providers and their availability."""
|
||||||
|
|
||||||
|
availability: Dict[str, bool] = {}
|
||||||
|
for name, provider_class in _PROVIDERS.items():
|
||||||
|
try:
|
||||||
|
provider = provider_class(config)
|
||||||
|
availability[name] = bool(provider.validate() and _supports_search(provider))
|
||||||
|
except Exception:
|
||||||
|
availability[name] = False
|
||||||
|
return availability
|
||||||
|
|
||||||
|
|
||||||
def get_file_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
|
def get_file_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
|
||||||
"""Get a file provider by name."""
|
"""Get an upload-capable provider by name (compat API)."""
|
||||||
|
|
||||||
provider_class = _FILE_PROVIDERS.get((name or "").lower())
|
provider = get_provider(name, config)
|
||||||
if provider_class is None:
|
if provider is None:
|
||||||
log(f"[provider] Unknown file provider: {name}", file=sys.stderr)
|
|
||||||
return None
|
return None
|
||||||
|
if not _supports_upload(provider):
|
||||||
try:
|
log(f"[provider] Provider '{name}' does not support upload", file=sys.stderr)
|
||||||
provider = provider_class(config)
|
|
||||||
if not provider.validate():
|
|
||||||
log(f"[provider] File provider '{name}' is not available", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
return provider
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"[provider] Error initializing file provider '{name}': {exc}", file=sys.stderr)
|
|
||||||
return None
|
return None
|
||||||
|
return provider # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
|
||||||
"""List all file providers and their availability."""
|
"""List all file providers and their availability."""
|
||||||
|
|
||||||
availability: Dict[str, bool] = {}
|
availability: Dict[str, bool] = {}
|
||||||
for name, provider_class in _FILE_PROVIDERS.items():
|
for name, provider_class in _PROVIDERS.items():
|
||||||
try:
|
try:
|
||||||
provider = provider_class(config)
|
provider = provider_class(config)
|
||||||
availability[name] = provider.validate()
|
availability[name] = bool(provider.validate() and _supports_upload(provider))
|
||||||
except Exception:
|
except Exception:
|
||||||
availability[name] = False
|
availability[name] = False
|
||||||
return availability
|
return availability
|
||||||
@@ -104,8 +130,11 @@ def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bo
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SearchResult",
|
"SearchResult",
|
||||||
|
"Provider",
|
||||||
"SearchProvider",
|
"SearchProvider",
|
||||||
"FileProvider",
|
"FileProvider",
|
||||||
|
"get_provider",
|
||||||
|
"list_providers",
|
||||||
"get_search_provider",
|
"get_search_provider",
|
||||||
"list_search_providers",
|
"list_search_providers",
|
||||||
"get_file_provider",
|
"get_file_provider",
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ class Store(ABC):
|
|||||||
"""Add or replace a named note for a file."""
|
"""Add or replace a named note for a file."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def selector(self, selected_items: List[Any], *, ctx: Any, stage_is_last: bool = True, **_kwargs: Any) -> bool:
|
||||||
|
"""Optional hook for handling `@N` selection semantics.
|
||||||
|
|
||||||
|
Return True if the selection was handled and default behavior should be skipped.
|
||||||
|
"""
|
||||||
|
_ = selected_items
|
||||||
|
_ = ctx
|
||||||
|
_ = stage_is_last
|
||||||
|
return False
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete_note(self, file_identifier: str, name: str, **kwargs: Any) -> bool:
|
def delete_note(self, file_identifier: str, name: str, **kwargs: Any) -> bool:
|
||||||
"""Delete a named note for a file."""
|
"""Delete a named note for a file."""
|
||||||
|
|||||||
@@ -1618,6 +1618,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
|
|||||||
pipe_obj = models.PipeObject(
|
pipe_obj = models.PipeObject(
|
||||||
hash=hash_val,
|
hash=hash_val,
|
||||||
store=store_val,
|
store=store_val,
|
||||||
|
provider=str(value.get("provider") or value.get("prov") or extra.get("provider") or "").strip() or None,
|
||||||
tag=tag_val,
|
tag=tag_val,
|
||||||
title=title_val,
|
title=title_val,
|
||||||
url=url_val,
|
url=url_val,
|
||||||
@@ -1660,6 +1661,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
|
|||||||
pipe_obj = models.PipeObject(
|
pipe_obj = models.PipeObject(
|
||||||
hash=hash_val,
|
hash=hash_val,
|
||||||
store=store_val,
|
store=store_val,
|
||||||
|
provider=None,
|
||||||
path=str(path_val) if path_val and path_val != "unknown" else None,
|
path=str(path_val) if path_val and path_val != "unknown" else None,
|
||||||
title=title_val,
|
title=title_val,
|
||||||
tag=[],
|
tag=[],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from SYS.logger import log, debug
|
|||||||
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
|
from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS
|
||||||
from Store import Store
|
from Store import Store
|
||||||
from . import _shared as sh
|
from . import _shared as sh
|
||||||
|
from result_table import ResultTable
|
||||||
|
|
||||||
Cmdlet = sh.Cmdlet
|
Cmdlet = sh.Cmdlet
|
||||||
CmdletArg = sh.CmdletArg
|
CmdletArg = sh.CmdletArg
|
||||||
@@ -49,6 +50,13 @@ class Add_File(Cmdlet):
|
|||||||
SharedArgs.STORE,
|
SharedArgs.STORE,
|
||||||
SharedArgs.HASH,
|
SharedArgs.HASH,
|
||||||
CmdletArg(name="provider", type="string", required=False, description="File hosting provider (e.g., 0x0)", alias="prov"),
|
CmdletArg(name="provider", type="string", required=False, description="File hosting provider (e.g., 0x0)", alias="prov"),
|
||||||
|
CmdletArg(
|
||||||
|
name="room",
|
||||||
|
type="string",
|
||||||
|
required=False,
|
||||||
|
description="Matrix room_id (when -provider matrix). If omitted, a room picker table is shown.",
|
||||||
|
alias="room_id",
|
||||||
|
),
|
||||||
CmdletArg(name="delete", type="flag", required=False, description="Delete file after successful upload", alias="del"),
|
CmdletArg(name="delete", type="flag", required=False, description="Delete file after successful upload", alias="del"),
|
||||||
],
|
],
|
||||||
detail=[
|
detail=[
|
||||||
@@ -70,6 +78,7 @@ class Add_File(Cmdlet):
|
|||||||
path_arg = parsed.get("path")
|
path_arg = parsed.get("path")
|
||||||
location = parsed.get("store")
|
location = parsed.get("store")
|
||||||
provider_name = parsed.get("provider")
|
provider_name = parsed.get("provider")
|
||||||
|
provider_room = parsed.get("room")
|
||||||
delete_after = parsed.get("delete", False)
|
delete_after = parsed.get("delete", False)
|
||||||
|
|
||||||
stage_ctx = ctx.get_stage_context()
|
stage_ctx = ctx.get_stage_context()
|
||||||
@@ -250,6 +259,30 @@ class Add_File(Cmdlet):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if provider_name:
|
if provider_name:
|
||||||
|
# Matrix provider can prompt for a room selection if one is not configured.
|
||||||
|
if str(provider_name).strip().lower() == "matrix":
|
||||||
|
room_id = None
|
||||||
|
if provider_room:
|
||||||
|
room_id = str(provider_room).strip()
|
||||||
|
if not room_id:
|
||||||
|
try:
|
||||||
|
matrix_conf = config.get("provider", {}).get("matrix", {}) if isinstance(config, dict) else {}
|
||||||
|
room_id = str(matrix_conf.get("room_id") or "").strip() or None
|
||||||
|
except Exception:
|
||||||
|
room_id = None
|
||||||
|
|
||||||
|
if not room_id:
|
||||||
|
pending = [
|
||||||
|
{
|
||||||
|
"path": str(media_path),
|
||||||
|
"pipe_obj": pipe_obj,
|
||||||
|
"delete_after": bool(delete_after_item),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return self._matrix_prompt_room_selection(pending, config, list(args))
|
||||||
|
|
||||||
|
code = self._handle_matrix_upload(media_path, pipe_obj, config, delete_after_item, room_id=room_id)
|
||||||
|
else:
|
||||||
code = self._handle_provider_upload(media_path, provider_name, pipe_obj, config, delete_after_item)
|
code = self._handle_provider_upload(media_path, provider_name, pipe_obj, config, delete_after_item)
|
||||||
if code == 0:
|
if code == 0:
|
||||||
successes += 1
|
successes += 1
|
||||||
@@ -1496,6 +1529,134 @@ class Add_File(Cmdlet):
|
|||||||
debug(f"[add-file] Soulseek download traceback: {e}")
|
debug(f"[add-file] Soulseek download traceback: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _handle_matrix_upload(
|
||||||
|
media_path: Path,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
delete_after: bool,
|
||||||
|
*,
|
||||||
|
room_id: str,
|
||||||
|
) -> int:
|
||||||
|
"""Upload to Matrix and update the PipeObject.
|
||||||
|
|
||||||
|
Matrix needs a room_id. If you don't have one, use the interactive
|
||||||
|
room picker path which resumes via `-matrix-send`.
|
||||||
|
"""
|
||||||
|
from Provider.matrix import Matrix
|
||||||
|
|
||||||
|
log(f"Uploading via matrix: {media_path.name}", file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = Matrix(config)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Matrix not available: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
hoster_url = provider.upload_to_room(str(media_path), str(room_id))
|
||||||
|
log(f"File uploaded: {hoster_url}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Associate URL with Hydrus if possible
|
||||||
|
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
|
||||||
|
if f_hash:
|
||||||
|
try:
|
||||||
|
store_name = getattr(pipe_obj, "store", None)
|
||||||
|
if store_name:
|
||||||
|
store = Store(config)
|
||||||
|
backend = store[str(store_name)]
|
||||||
|
client = getattr(backend, "_client", None)
|
||||||
|
if client is not None and hasattr(client, "associate_url"):
|
||||||
|
client.associate_url(str(f_hash), hoster_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Upload failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Update PipeObject and emit
|
||||||
|
extra_updates: Dict[str, Any] = {
|
||||||
|
"provider": "matrix",
|
||||||
|
"provider_url": hoster_url,
|
||||||
|
"room_id": str(room_id),
|
||||||
|
}
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
existing_known = list(pipe_obj.extra.get("url") or [])
|
||||||
|
if hoster_url and hoster_url not in existing_known:
|
||||||
|
existing_known.append(hoster_url)
|
||||||
|
extra_updates["url"] = existing_known
|
||||||
|
|
||||||
|
file_path = pipe_obj.path or (str(media_path) if media_path else None) or ""
|
||||||
|
Add_File._update_pipe_object_destination(
|
||||||
|
pipe_obj,
|
||||||
|
hash_value=f_hash or "unknown",
|
||||||
|
store="matrix",
|
||||||
|
path=file_path,
|
||||||
|
tag=pipe_obj.tag,
|
||||||
|
title=pipe_obj.title or (media_path.name if media_path else None),
|
||||||
|
extra_updates=extra_updates,
|
||||||
|
)
|
||||||
|
Add_File._emit_pipe_object(pipe_obj)
|
||||||
|
Add_File._cleanup_after_success(media_path, delete_source=bool(delete_after))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matrix_prompt_room_selection(
|
||||||
|
pending_items: List[Dict[str, Any]],
|
||||||
|
config: Dict[str, Any],
|
||||||
|
original_args: List[str],
|
||||||
|
) -> int:
|
||||||
|
"""Show rooms table and pause pipeline for @N selection."""
|
||||||
|
from Provider.matrix import Matrix
|
||||||
|
|
||||||
|
# Stash pending uploads so @N on the matrix table can trigger Matrix.upload_to_room.
|
||||||
|
ctx.store_value("matrix_pending_uploads", pending_items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = Matrix(config)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Matrix not available: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
rooms = provider.list_rooms()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Failed to list Matrix rooms: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not rooms:
|
||||||
|
log("No joined rooms found.", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
table = ResultTable("Matrix Rooms")
|
||||||
|
table.set_table("matrix")
|
||||||
|
table.set_source_command("add-file", list(original_args or []))
|
||||||
|
|
||||||
|
for room in rooms:
|
||||||
|
row = table.add_row()
|
||||||
|
name = str(room.get("name") or "").strip() if isinstance(room, dict) else ""
|
||||||
|
rid = str(room.get("room_id") or "").strip() if isinstance(room, dict) else ""
|
||||||
|
row.add_column("Name", name)
|
||||||
|
row.add_column("Room", rid)
|
||||||
|
|
||||||
|
room_items: List[Dict[str, Any]] = []
|
||||||
|
for room in rooms:
|
||||||
|
if not isinstance(room, dict):
|
||||||
|
continue
|
||||||
|
rid = str(room.get("room_id") or "").strip()
|
||||||
|
name = str(room.get("name") or "").strip()
|
||||||
|
room_items.append({**room, "store": "matrix", "provider": "matrix", "title": name or rid or "Matrix Room"})
|
||||||
|
|
||||||
|
# Overlay table: user selects @N on this Matrix rooms table to upload.
|
||||||
|
ctx.set_last_result_table_overlay(table, room_items)
|
||||||
|
ctx.set_current_stage_table(table)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(table.format_plain())
|
||||||
|
print("\nSelect room(s) with @N (e.g. @1 or @1-3) to upload the selected item(s)")
|
||||||
|
return 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_provider_upload(
|
def _handle_provider_upload(
|
||||||
media_path: Path,
|
media_path: Path,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from SYS.download import DownloadError, _download_direct_file
|
from SYS.download import DownloadError, _download_direct_file
|
||||||
from SYS.logger import log, debug
|
from SYS.logger import log, debug
|
||||||
@@ -102,7 +103,7 @@ class Download_File(Cmdlet):
|
|||||||
get_search_provider = None
|
get_search_provider = None
|
||||||
SearchResult = None
|
SearchResult = None
|
||||||
|
|
||||||
def _emit_local_file(downloaded_path: Path, source: Optional[str], title_hint: Optional[str], tags_hint: Optional[List[str]], media_kind_hint: Optional[str], full_metadata: Optional[Dict[str, Any]]) -> None:
|
def _emit_local_file(downloaded_path: Path, source: Optional[str], title_hint: Optional[str], tags_hint: Optional[List[str]], media_kind_hint: Optional[str], full_metadata: Optional[Dict[str, Any]], provider_hint: Optional[str] = None) -> None:
|
||||||
title_val = (title_hint or downloaded_path.stem or "Unknown").strip() or downloaded_path.stem
|
title_val = (title_hint or downloaded_path.stem or "Unknown").strip() or downloaded_path.stem
|
||||||
hash_value = self._compute_file_hash(downloaded_path)
|
hash_value = self._compute_file_hash(downloaded_path)
|
||||||
tag: List[str] = []
|
tag: List[str] = []
|
||||||
@@ -121,6 +122,8 @@ class Download_File(Cmdlet):
|
|||||||
"media_kind": media_kind_hint or "file",
|
"media_kind": media_kind_hint or "file",
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
}
|
}
|
||||||
|
if provider_hint:
|
||||||
|
payload["provider"] = str(provider_hint)
|
||||||
if full_metadata:
|
if full_metadata:
|
||||||
payload["full_metadata"] = full_metadata
|
payload["full_metadata"] = full_metadata
|
||||||
if source and str(source).startswith("http"):
|
if source and str(source).startswith("http"):
|
||||||
@@ -140,6 +143,79 @@ class Download_File(Cmdlet):
|
|||||||
try:
|
try:
|
||||||
debug(f"Processing URL: {url}")
|
debug(f"Processing URL: {url}")
|
||||||
|
|
||||||
|
# Telegram message URLs are not direct files; route through the provider.
|
||||||
|
try:
|
||||||
|
parsed = urlparse(str(url))
|
||||||
|
host = (parsed.hostname or "").lower().strip()
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
|
||||||
|
is_telegram = host in {"t.me", "telegram.me"} or host.endswith(".t.me")
|
||||||
|
if is_telegram and SearchResult:
|
||||||
|
try:
|
||||||
|
from ProviderCore.registry import get_provider as _get_provider
|
||||||
|
except Exception:
|
||||||
|
_get_provider = None
|
||||||
|
|
||||||
|
if _get_provider is None:
|
||||||
|
raise DownloadError("Telegram provider registry not available")
|
||||||
|
|
||||||
|
provider = _get_provider("telegram", config)
|
||||||
|
if provider is None:
|
||||||
|
raise DownloadError("Telegram provider not configured or not available (check telethon/app_id/api_hash)")
|
||||||
|
|
||||||
|
sr = SearchResult(table="telegram", title=str(url), path=str(url), full_metadata={})
|
||||||
|
downloaded_path = None
|
||||||
|
telegram_info: Optional[Dict[str, Any]] = None
|
||||||
|
if hasattr(provider, "download_url"):
|
||||||
|
try:
|
||||||
|
downloaded_path, telegram_info = provider.download_url(str(url), final_output_dir) # type: ignore[attr-defined]
|
||||||
|
except Exception as exc:
|
||||||
|
raise DownloadError(str(exc))
|
||||||
|
else:
|
||||||
|
downloaded_path = provider.download(sr, final_output_dir)
|
||||||
|
|
||||||
|
if not downloaded_path:
|
||||||
|
raise DownloadError("Telegram download returned no file")
|
||||||
|
|
||||||
|
channel = ""
|
||||||
|
post = None
|
||||||
|
if isinstance(telegram_info, dict):
|
||||||
|
try:
|
||||||
|
chat_info = telegram_info.get("chat") if isinstance(telegram_info.get("chat"), dict) else {}
|
||||||
|
msg_info = telegram_info.get("message") if isinstance(telegram_info.get("message"), dict) else {}
|
||||||
|
channel = str(chat_info.get("title") or chat_info.get("username") or "").strip()
|
||||||
|
post = msg_info.get("id")
|
||||||
|
except Exception:
|
||||||
|
channel = ""
|
||||||
|
post = None
|
||||||
|
|
||||||
|
title_hint = None
|
||||||
|
tags_hint: List[str] = []
|
||||||
|
if channel:
|
||||||
|
tags_hint.append(f"channel:{channel}")
|
||||||
|
if post is not None:
|
||||||
|
tags_hint.append(f"post:{post}")
|
||||||
|
if channel and post is not None:
|
||||||
|
title_hint = f"{channel} {post}"
|
||||||
|
elif post is not None:
|
||||||
|
title_hint = f"post:{post}"
|
||||||
|
else:
|
||||||
|
title_hint = downloaded_path.stem
|
||||||
|
|
||||||
|
_emit_local_file(
|
||||||
|
downloaded_path=downloaded_path,
|
||||||
|
source=str(url),
|
||||||
|
title_hint=title_hint,
|
||||||
|
tags_hint=tags_hint,
|
||||||
|
media_kind_hint="file",
|
||||||
|
full_metadata=telegram_info,
|
||||||
|
provider_hint="telegram",
|
||||||
|
)
|
||||||
|
downloaded_count += 1
|
||||||
|
debug("✓ Downloaded via Telegram provider and emitted")
|
||||||
|
continue
|
||||||
|
|
||||||
result_obj = _download_direct_file(url, final_output_dir, quiet=quiet_mode)
|
result_obj = _download_direct_file(url, final_output_dir, quiet=quiet_mode)
|
||||||
file_path = None
|
file_path = None
|
||||||
if hasattr(result_obj, "path"):
|
if hasattr(result_obj, "path"):
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class PipeObject:
|
|||||||
"""
|
"""
|
||||||
hash: str
|
hash: str
|
||||||
store: str
|
store: str
|
||||||
|
provider: Optional[str] = None
|
||||||
tag: List[str] = field(default_factory=list)
|
tag: List[str] = field(default_factory=list)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
@@ -90,6 +91,7 @@ class PipeObject:
|
|||||||
# Prepare display values
|
# Prepare display values
|
||||||
hash_display = str(self.hash or "N/A")
|
hash_display = str(self.hash or "N/A")
|
||||||
store_display = str(self.store or "N/A")
|
store_display = str(self.store or "N/A")
|
||||||
|
provider_display = str(self.provider or "N/A")
|
||||||
title_display = str(self.title or "N/A")
|
title_display = str(self.title or "N/A")
|
||||||
tag_display = ", ".join(self.tag[:3]) if self.tag else "[]"
|
tag_display = ", ".join(self.tag[:3]) if self.tag else "[]"
|
||||||
if len(self.tag) > 3:
|
if len(self.tag) > 3:
|
||||||
@@ -134,6 +136,7 @@ class PipeObject:
|
|||||||
rows = [
|
rows = [
|
||||||
("Hash", hash_display),
|
("Hash", hash_display),
|
||||||
("Store", store_display),
|
("Store", store_display),
|
||||||
|
("Provider", provider_display),
|
||||||
("Title", title_display),
|
("Title", title_display),
|
||||||
("Tag", tag_display),
|
("Tag", tag_display),
|
||||||
("URL", str(url_display)),
|
("URL", str(url_display)),
|
||||||
@@ -227,6 +230,9 @@ class PipeObject:
|
|||||||
"store": self.store,
|
"store": self.store,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.provider:
|
||||||
|
data["provider"] = self.provider
|
||||||
|
|
||||||
if self.tag:
|
if self.tag:
|
||||||
data["tag"] = self.tag
|
data["tag"] = self.tag
|
||||||
if self.title:
|
if self.title:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ yt-dlp[default]>=2023.11.0
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
ffmpeg-python>=0.2.0
|
ffmpeg-python>=0.2.0
|
||||||
|
telethon>=1.36.0
|
||||||
|
|
||||||
# Document and data handling
|
# Document and data handling
|
||||||
pypdf>=3.0.0
|
pypdf>=3.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user