From c6fd6b42245130e41abade4db6b27ec429b47aa9 Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 17 Jan 2026 02:36:06 -0800 Subject: [PATCH] f --- API/data/alldebrid.json | 2 +- MPV/mpv_ipc.py | 2 +- MPV/pipeline_helper.py | 2 +- Provider/alldebrid.py | 47 +++++-- Provider/ytdlp.py | 20 +-- cmdlet/_shared.py | 226 +++++++++++++++++++++++++------- cmdlet/download_file.py | 281 ++++++++++++++++++++-------------------- cmdlet/get_url.py | 80 +++++++++--- tool/ytdlp.py | 6 +- 9 files changed, 440 insertions(+), 226 deletions(-) diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index fea52ee..a70390d 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -92,7 +92,7 @@ "(hitfile\\.net/[a-z0-9A-Z]{4,9})" ], "regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))", - "status": true + "status": false }, "mega": { "name": "mega", diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index 7ada01d..db012a0 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -344,7 +344,7 @@ class MPV: def _q(s: str) -> str: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' - pipeline = f"download-file -url {_q(url)} -format {_q(fmt)}" + pipeline = f"download-file -url {_q(url)} -query {_q(f'format:{fmt}')}" if store: pipeline += f" | add-file -store {_q(store)}" else: diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index e92f4ab..6e6cd8a 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -518,7 +518,7 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx")) # Build selection args compatible with MPV Lua picker. - selection_args = ["-format", format_id] + selection_args = ["-query", f"format:{format_id}"] rows.append( { diff --git a/Provider/alldebrid.py b/Provider/alldebrid.py index ff695da..6544be4 100644 --- a/Provider/alldebrid.py +++ b/Provider/alldebrid.py @@ -266,6 +266,22 @@ def _fetch_torrent_bytes(target: str) -> Optional[bytes]: return None +_ALD_MAGNET_PREFIX = "alldebrid:magnet:" + + +def _parse_alldebrid_magnet_id(target: str) -> Optional[int]: + candidate = str(target or "").strip() + if not candidate: + return None + if not candidate.lower().startswith(_ALD_MAGNET_PREFIX): + return None + try: + magnet_id_raw = candidate[len(_ALD_MAGNET_PREFIX):].strip() + return int(magnet_id_raw) + except Exception: + return None + + def resolve_magnet_spec(target: str) -> Optional[str]: """Resolve a magnet/hash/torrent URL into a magnet/hash string.""" candidate = str(target or "").strip() @@ -558,14 +574,14 @@ class AllDebrid(TableProviderMixin, Provider): 1. User runs: search-file -provider alldebrid "ubuntu" 2. Results show magnet folders and (optionally) files 3. User selects a row: @1 - 4. Selection metadata routes to download-file with -magnet-id - 5. download-file calls provider.download_items() with magnet_id + 4. Selection metadata routes to download-file with -url alldebrid:magnet: + 5. download-file invokes provider.download_items() via provider URL handling 6. Provider fetches files, unlocks locked URLs, and downloads """ # Magnet URIs should be routed through this provider. TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]} AUTO_STAGE_USE_SELECTION_ARGS = True - URL = ("magnet:",) + URL = ("magnet:", "alldebrid:magnet:") URL_DOMAINS = () @classmethod @@ -631,6 +647,19 @@ class AllDebrid(TableProviderMixin, Provider): return resolve_magnet_spec(str(target)) if isinstance(target, str) else None def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]: + magnet_id = _parse_alldebrid_magnet_id(url) + if magnet_id is not None: + return True, { + "action": "download_items", + "path": f"{_ALD_MAGNET_PREFIX}{magnet_id}", + "title": f"magnet-{magnet_id}", + "metadata": { + "magnet_id": magnet_id, + "provider": "alldebrid", + "provider_view": "files", + }, + } + spec = resolve_magnet_spec(url) if not spec: return False, None @@ -1180,8 +1209,8 @@ class AllDebrid(TableProviderMixin, Provider): "provider": "alldebrid", "provider_view": "files", # Selection metadata for table system - "_selection_args": ["-magnet-id", str(magnet_id)], - "_selection_action": ["download-file", "-provider", "alldebrid", "-magnet-id", str(magnet_id)], + "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], + "_selection_action": ["download-file", "-provider", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], } results.append( @@ -1295,8 +1324,8 @@ class AllDebrid(TableProviderMixin, Provider): "provider_view": "folders", "magnet_name": magnet_name, # Selection metadata: allow @N expansion to drive downloads directly - "_selection_args": ["-magnet-id", str(magnet_id)], - "_selection_action": ["download-file", "-provider", "alldebrid", "-magnet-id", str(magnet_id)], + "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], + "_selection_action": ["download-file", "-provider", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], }, ) ) @@ -1585,7 +1614,7 @@ try: 1. Explicit _selection_action (full command args) 2. Explicit _selection_args (URL-specific args) 3. Magic routing based on provider_view (files vs folders) - 4. Magnet ID routing for folder-type rows + 4. Magnet ID routing for folder-type rows (via alldebrid:magnet:) 5. Direct URL for file rows This ensures that selector overrides all pre-codes and gives users full power. @@ -1612,7 +1641,7 @@ try: # Folder rows: use magnet_id to fetch and download all files magnet_id = metadata.get("magnet_id") if magnet_id is not None: - return ["-magnet-id", str(magnet_id)] + return ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"] # Fallback: try direct URL if row.path: diff --git a/Provider/ytdlp.py b/Provider/ytdlp.py index 3f701c2..b0ea6c9 100644 --- a/Provider/ytdlp.py +++ b/Provider/ytdlp.py @@ -2,7 +2,7 @@ When a URL is passed through download-file, this provider displays available formats in a table and routes format selection back to download-file with the chosen format -already specified via -format, skipping the format table on the second invocation. +already specified via -query "format:", skipping the format table on the second invocation. This keeps format selection logic in ytdlp and leaves add-file plug-and-play. """ @@ -31,8 +31,8 @@ class ytdlp(TableProviderMixin, Provider): - User runs: download-file "https://example.com/video" - If URL is ytdlp-supported and no format specified, displays format table - User selects @N (e.g., @3 for format index 3) - - Selection args include -format , re-invoking download-file - - Second download-file call sees -format and skips table, downloads directly + - Selection args include -query "format:", re-invoking download-file + - Second download-file call sees the format query and skips the table, downloads directly SEARCH USAGE: - User runs: search-file -provider ytdlp "linux tutorial" @@ -41,12 +41,12 @@ class ytdlp(TableProviderMixin, Provider): - Selection args route to download-file for streaming download SELECTION FLOW (Format): - 1. download-file receives URL without -format + 1. download-file receives URL without a format query 2. Calls ytdlp to list formats 3. Returns formats as ResultTable (from this provider) 4. User selects @N - 5. Selection args: ["-format", ""] route back to download-file - 6. Second download-file invocation with -format skips table + 5. Selection args: ["-query", "format:"] route back to download-file + 6. Second download-file invocation with format query skips table SELECTION FLOW (Search): 1. search-file lists YouTube videos via yt_dlp @@ -56,7 +56,7 @@ class ytdlp(TableProviderMixin, Provider): 5. download-file downloads the selected video TABLE AUTO-STAGES: - - Format selection: ytdlp.formatlist -> download-file (with -format) + - Format selection: ytdlp.formatlist -> download-file (with -query format:) - Video search: ytdlp.search -> download-file (with -url) SUPPORTED URLS: @@ -106,7 +106,7 @@ class ytdlp(TableProviderMixin, Provider): "ytdlp.formatlist": ["download-file"], "ytdlp.search": ["download-file"], } - # Forward selection args (including -format or -url) to the next stage + # Forward selection args (including -query format:... or -url) to the next stage AUTO_STAGE_USE_SELECTION_ARGS = True def search( @@ -277,7 +277,7 @@ try: """Return selection args for format selection. When user selects @N, these args are passed to download-file which sees - the -format specifier and skips the format table, downloading directly. + the format query and skips the format table, downloading directly. """ metadata = row.metadata or {} @@ -291,7 +291,7 @@ try: # Fallback: use format_id format_id = metadata.get("format_id") or metadata.get("id") if format_id: - result_args = ["-format", str(format_id)] + result_args = ["-query", f"format:{format_id}"] debug(f"[ytdlp] Selection routed with format_id: {format_id}") return result_args diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index e456093..d8e20b6 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -10,6 +10,7 @@ import shutil import sys import tempfile from collections.abc import Iterable as IterableABC +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from SYS.logger import log, debug from pathlib import Path @@ -3074,6 +3075,19 @@ def check_url_exists_in_storage( pass return False + def _load_preflight_cache() -> Dict[str, Any]: + try: + existing = pipeline_context.load_value("preflight", default=None) + except Exception: + existing = None + return existing if isinstance(existing, dict) else {} + + def _store_preflight_cache(cache: Dict[str, Any]) -> None: + try: + pipeline_context.store_value("preflight", cache) + except Exception: + pass + unique_urls: List[str] = [] for u in urls or []: s = str(u or "").strip() @@ -3093,6 +3107,56 @@ def check_url_exists_in_storage( except Exception: return False + def _expand_url_variants(value: str) -> List[str]: + if not _httpish(value): + return [] + + try: + parsed = urlparse(value) + except Exception: + return [] + + if parsed.scheme.lower() not in {"http", "https"}: + return [] + + out: List[str] = [] + + def _maybe_add(candidate: str) -> None: + if not candidate or candidate == value: + return + if candidate not in out: + out.append(candidate) + + if parsed.fragment: + _maybe_add(urlunparse(parsed._replace(fragment=""))) + + time_keys = {"t", "start", "time_continue", "timestamp", "time", "begin"} + tracking_prefixes = ("utm_",) + + try: + query_pairs = parse_qsl(parsed.query, keep_blank_values=True) + except Exception: + query_pairs = [] + + if query_pairs or parsed.fragment: + filtered_pairs = [] + removed = False + for key, val in query_pairs: + key_norm = str(key or "").lower() + if key_norm in time_keys: + removed = True + continue + if key_norm.startswith(tracking_prefixes): + removed = True + continue + filtered_pairs.append((key, val)) + + if removed: + new_query = urlencode(filtered_pairs, doseq=True) if filtered_pairs else "" + _maybe_add(urlunparse(parsed._replace(query=new_query, fragment=""))) + + return out + url_needles: Dict[str, List[str]] = {} for u in unique_urls: needles: List[str] = [] @@ -3112,7 +3176,88 @@ def check_url_exists_in_storage( continue if n2 not in filtered: filtered.append(n2) - url_needles[u] = filtered if filtered else [u] + expanded: List[str] = [] + for n2 in filtered: + for extra in _expand_url_variants(n2): + if extra not in expanded and extra not in filtered: + expanded.append(extra) + + combined = filtered + expanded + url_needles[u] = combined if combined else [u] + + if in_pipeline: + preflight_cache = _load_preflight_cache() + url_dup_cache = preflight_cache.get("url_duplicates") + if not isinstance(url_dup_cache, dict): + url_dup_cache = {} + cached_urls = url_dup_cache.get("urls") + cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set() + + if cached_set: + all_cached = True + for original_url, needles in url_needles.items(): + if original_url in cached_set: + continue + if any(n in cached_set for n in (needles or [])): + continue + all_cached = False + break + + if all_cached: + debug("Bulk URL preflight: cached for pipeline; skipping duplicate check") + return True + + def _search_backend_url_hits( + backend: Any, + backend_name: str, + original_url: str, + needles: Sequence[str], + ) -> Optional[Dict[str, Any]]: + backend_hits: List[Dict[str, Any]] = [] + for needle in (needles or [])[:3]: + try: + backend_hits = backend.search(f"url:{needle}", limit=1) or [] + if backend_hits: + break + except Exception: + continue + + if not backend_hits: + return None + + hit = backend_hits[0] + title = hit.get("title") or hit.get("name") or hit.get("target") or hit.get("path") or "(exists)" + file_hash = hit.get("hash") or hit.get("file_hash") or hit.get("sha256") or "" + + try: + from SYS.result_table import build_display_row + extracted = build_display_row(hit, keys=["title", "store", "hash", "ext", "size"]) + except Exception: + extracted = {} + + extracted["title"] = str(title) + extracted["store"] = str(hit.get("store") or backend_name) + extracted["hash"] = str(file_hash or "") + + ext = extracted.get("ext") + size_val = extracted.get("size") + + return { + "title": str(title), + "store": str(hit.get("store") or backend_name), + "hash": str(file_hash or ""), + "ext": str(ext or ""), + "size": size_val, + "url": original_url, + "columns": [ + ("Title", str(title)), + ("Store", str(hit.get("store") or backend_name)), + ("Hash", str(file_hash or "")), + ("Ext", str(ext or "")), + ("Size", size_val), + ("URL", original_url), + ], + } backend_names: List[str] = [] try: @@ -3167,12 +3312,11 @@ def check_url_exists_in_storage( continue if HydrusNetwork is not None and isinstance(backend, HydrusNetwork): - if not hydrus_available: - continue - client = getattr(backend, "_client", None) if client is None: continue + if not hydrus_available: + debug("Bulk URL preflight: hydrus availability check failed; attempting best-effort lookup") for original_url, needles in url_needles.items(): if len(match_rows) >= max_rows: @@ -3214,6 +3358,11 @@ def check_url_exists_in_storage( continue if not found: + fallback_row = _search_backend_url_hits(backend, str(backend_name), original_url, needles) + if fallback_row: + seen_pairs.add((original_url, str(backend_name))) + matched_urls.add(original_url) + match_rows.append(fallback_row) continue seen_pairs.add((original_url, str(backend_name))) @@ -3239,57 +3388,33 @@ def check_url_exists_in_storage( if (original_url, str(backend_name)) in seen_pairs: continue - backend_hits: List[Dict[str, Any]] = [] - for needle in (needles or [])[:3]: - try: - backend_hits = backend.search(f"url:{needle}", limit=1) or [] - if backend_hits: - break - except Exception: - continue - - if not backend_hits: + display_row = _search_backend_url_hits(backend, str(backend_name), original_url, needles) + if not display_row: continue seen_pairs.add((original_url, str(backend_name))) matched_urls.add(original_url) - hit = backend_hits[0] - title = hit.get("title") or hit.get("name") or hit.get("target") or hit.get("path") or "(exists)" - file_hash = hit.get("hash") or hit.get("file_hash") or hit.get("sha256") or "" - - try: - from SYS.result_table import build_display_row - extracted = build_display_row(hit, keys=["title", "store", "hash", "ext", "size"]) - except Exception: - extracted = {} - - extracted["title"] = str(title) - extracted["store"] = str(hit.get("store") or backend_name) - extracted["hash"] = str(file_hash or "") - - ext = extracted.get("ext") - size_val = extracted.get("size") - - display_row = { - "title": str(title), - "store": str(hit.get("store") or backend_name), - "hash": str(file_hash or ""), - "ext": str(ext or ""), - "size": size_val, - "url": original_url, - "columns": [ - ("Title", str(title)), - ("Store", str(hit.get("store") or backend_name)), - ("Hash", str(file_hash or "")), - ("Ext", str(ext or "")), - ("Size", size_val), - ("URL", original_url), - ], - } match_rows.append(display_row) if not match_rows: debug("Bulk URL preflight: no matches") + if in_pipeline: + preflight_cache = _load_preflight_cache() + url_dup_cache = preflight_cache.get("url_duplicates") + if not isinstance(url_dup_cache, dict): + url_dup_cache = {} + + cached_urls = url_dup_cache.get("urls") + cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set() + + for original_url, needles in url_needles.items(): + cached_set.add(original_url) + for needle in needles or []: + cached_set.add(str(needle)) + + url_dup_cache["urls"] = sorted(cached_set) + preflight_cache["url_duplicates"] = url_dup_cache + _store_preflight_cache(preflight_cache) return True table = ResultTable(f"URL already exists ({len(matched_urls)} url(s))", max_columns=10) @@ -3333,6 +3458,13 @@ def check_url_exists_in_storage( url_dup_cache = {} url_dup_cache["command"] = str(current_cmd_text or "") url_dup_cache["continue"] = bool(answered_yes) + cached_urls = url_dup_cache.get("urls") + cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set() + for original_url, needles in url_needles.items(): + cached_set.add(original_url) + for needle in needles or []: + cached_set.add(str(needle)) + url_dup_cache["urls"] = sorted(cached_set) preflight_cache["url_duplicates"] = url_dup_cache try: pipeline_context.store_value("preflight", preflight_cache) diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 0be052a..6937d46 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -81,23 +81,6 @@ class Download_File(Cmdlet): alias="o", description="(deprecated) Output directory (use -path instead)", ), - CmdletArg( - name="audio", - type="flag", - alias="a", - description="Download audio only (yt-dlp)", - ), - CmdletArg( - name="-magnet-id", - type="string", - description="(internal) AllDebrid magnet id used by provider selection hooks", - ), - CmdletArg( - name="format", - type="string", - alias="fmt", - description="Explicit yt-dlp format selector", - ), QueryArg( "clip", key="clip", @@ -183,6 +166,42 @@ class Download_File(Cmdlet): path_value: Optional[Any] = path if isinstance(path, dict): + provider_action = str( + path.get("action") + or path.get("provider_action") + or "" + ).strip().lower() + if provider_action == "download_items" or bool(path.get("download_items")): + request_metadata = path.get("metadata") or path.get("full_metadata") or {} + if not isinstance(request_metadata, dict): + request_metadata = {} + magnet_id = path.get("magnet_id") or request_metadata.get("magnet_id") + if magnet_id is not None: + request_metadata.setdefault("magnet_id", magnet_id) + + if SearchResult is None: + debug("Provider download_items requested but SearchResult unavailable") + continue + + sr = SearchResult( + table=str(provider_name), + title=str(path.get("title") or path.get("name") or f"{provider_name} item"), + path=str(path.get("path") or path.get("url") or url), + full_metadata=request_metadata, + ) + downloaded_extra = self._download_provider_items( + provider=provider, + provider_name=str(provider_name), + search_result=sr, + output_dir=final_output_dir, + progress=progress, + quiet_mode=quiet_mode, + config=config, + ) + if downloaded_extra: + downloaded_count += int(downloaded_extra) + continue + path_value = path.get("path") or path.get("file_path") extra_meta = path.get("metadata") or path.get("full_metadata") title_hint = path.get("title") or path.get("name") @@ -451,6 +470,24 @@ class Download_File(Cmdlet): provider_sr = sr debug(f"[download-file] Provider download result: {downloaded_path}") + if downloaded_path is None: + try: + downloaded_extra = self._download_provider_items( + provider=provider_obj, + provider_name=str(provider_key), + search_result=sr, + output_dir=output_dir, + progress=progress, + quiet_mode=quiet_mode, + config=config, + ) + except Exception: + downloaded_extra = 0 + + if downloaded_extra: + downloaded_count += int(downloaded_extra) + continue + # Fallback: if we have a direct HTTP URL and no provider successfully handled it if (downloaded_path is None and not attempted_provider_download and isinstance(target, str) and target.startswith("http")): @@ -539,6 +576,68 @@ class Download_File(Cmdlet): return downloaded_count, queued_magnet_submissions + def _download_provider_items( + self, + *, + provider: Any, + provider_name: str, + search_result: Any, + output_dir: Path, + progress: PipelineProgress, + quiet_mode: bool, + config: Dict[str, Any], + ) -> int: + if provider is None or not hasattr(provider, "download_items"): + return 0 + + def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: + title_hint = None + try: + title_hint = metadata.get("name") or relpath + except Exception: + title_hint = relpath + title_hint = title_hint or (Path(path).name if path else "download") + + self._emit_local_file( + downloaded_path=path, + source=file_url, + title_hint=title_hint, + tags_hint=None, + media_kind_hint="file", + full_metadata=metadata if isinstance(metadata, dict) else None, + progress=progress, + config=config, + provider_hint=provider_name, + ) + + try: + downloaded_count = provider.download_items( + search_result, + output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=coerce_to_path, + config=config, + ) + except TypeError: + downloaded_count = provider.download_items( + search_result, + output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=coerce_to_path, + ) + except Exception as exc: + log(f"Provider {provider_name} download_items error: {exc}", file=sys.stderr) + return 0 + + try: + return int(downloaded_count or 0) + except Exception: + return 0 + def _emit_local_file( self, *, @@ -1221,11 +1320,27 @@ class Download_File(Cmdlet): # Add base command for display format_dict["cmd"] = base_cmd + def _merge_query_args(selection_args: List[str], query_value: str) -> List[str]: + if not query_value: + return selection_args + merged = list(selection_args or []) + if "-query" in merged: + idx_query = merged.index("-query") + if idx_query + 1 < len(merged): + existing = str(merged[idx_query + 1] or "").strip() + merged[idx_query + 1] = f"{existing},{query_value}" if existing else query_value + else: + merged.append(query_value) + else: + merged.extend(["-query", query_value]) + return merged + # Append clip values to selection args if needed - selection_args: List[str] = format_dict["_selection_args"].copy() + selection_args: List[str] = list(format_dict.get("_selection_args") or []) try: if (not clip_spec) and clip_values: - selection_args.extend(["-query", f"clip:{','.join([v for v in clip_values if v])}"]) + clip_query = f"clip:{','.join([v for v in clip_values if v])}" + selection_args = _merge_query_args(selection_args, clip_query) except Exception: pass format_dict["_selection_args"] = selection_args @@ -1253,7 +1368,9 @@ class Download_File(Cmdlet): pipeline_context.set_last_result_table(table, results_list) debug(f"[ytdlp.formatlist] Format table registered with {len(results_list)} formats") - debug(f"[ytdlp.formatlist] When user selects @N, will invoke: download-file {url} -format ") + debug( + f"[ytdlp.formatlist] When user selects @N, will invoke: download-file {url} -query 'format:'" + ) log(f"", file=sys.stderr) return 0 @@ -1518,7 +1635,7 @@ class Download_File(Cmdlet): "url": url, "item_selector": selection_format_id, }, - "_selection_args": ["-format", selection_format_id], + "_selection_args": ["-query", f"format:{selection_format_id}"], } results_list.append(format_dict) @@ -1748,12 +1865,10 @@ class Download_File(Cmdlet): except Exception: query_wants_audio = False - audio_flag = bool(parsed.get("audio") is True) - wants_audio = audio_flag if query_audio is not None: - wants_audio = wants_audio or bool(query_audio) + wants_audio = bool(query_audio) else: - wants_audio = wants_audio or bool(query_wants_audio) + wants_audio = bool(query_wants_audio) mode = "audio" if wants_audio else "video" clip_ranges, clip_invalid, clip_values = self._parse_clip_ranges_and_apply_items( @@ -1777,8 +1892,8 @@ class Download_File(Cmdlet): formats_cache: Dict[str, Optional[List[Dict[str, Any]]]] = {} playlist_items = str(parsed.get("item")) if parsed.get("item") else None - ytdl_format = parsed.get("format") - if not ytdl_format and query_format and not query_wants_audio: + ytdl_format = None + if query_format and not query_wants_audio: try: height_selector = self._format_selector_for_query_height(query_format) except ValueError as e: @@ -1825,7 +1940,7 @@ class Download_File(Cmdlet): sample_pipeline = f'download-file "{candidate_url}"' hint = ( "To select non-interactively, re-run with an explicit format: " - "e.g. mm \"{pipeline} -format {fmt} | add-file -store \" or " + "e.g. mm \"{pipeline} -query 'format:{fmt}' | add-file -store \" or " "mm \"{pipeline} -query 'format:{index}' | add-file -store \"" ).format( pipeline=sample_pipeline, @@ -2735,18 +2850,6 @@ class Download_File(Cmdlet): downloaded_count = 0 - # Special-case: support selection-inserted magnet-id arg to drive provider downloads - magnet_ret = self._process_magnet_id( - parsed=parsed, - registry=registry, - config=config, - final_output_dir=final_output_dir, - progress=progress, - quiet_mode=quiet_mode - ) - if magnet_ret is not None: - return magnet_ret - urls_downloaded, early_exit = self._process_explicit_urls( raw_urls=raw_url, final_output_dir=final_output_dir, @@ -2800,104 +2903,6 @@ class Download_File(Cmdlet): pass progress.close_local_ui(force_complete=True) - def _process_magnet_id( - self, - *, - parsed: Dict[str, Any], - registry: Dict[str, Any], - config: Dict[str, Any], - final_output_dir: Path, - progress: PipelineProgress, - quiet_mode: bool - ) -> Optional[int]: - magnet_id_raw = parsed.get("magnet-id") - if not magnet_id_raw: - return None - - try: - magnet_id = int(str(magnet_id_raw).strip()) - except Exception: - log(f"[download-file] invalid magnet-id: {magnet_id_raw}", file=sys.stderr) - return 1 - - get_provider = registry.get("get_provider") - provider_name = str(parsed.get("provider") or "alldebrid").strip().lower() - provider_obj = None - if get_provider is not None: - try: - provider_obj = get_provider(provider_name, config) - except Exception: - provider_obj = None - - if provider_obj is None: - log(f"[download-file] provider '{provider_name}' not available", file=sys.stderr) - return 1 - - SearchResult = registry.get("SearchResult") - try: - if SearchResult is not None: - sr = SearchResult( - table=provider_name, - title=f"magnet-{magnet_id}", - path=f"alldebrid:magnet:{magnet_id}", - full_metadata={ - "magnet_id": magnet_id, - "provider": provider_name, - "provider_view": "files", - }, - ) - else: - sr = None - except Exception: - sr = None - - def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: - title_hint = metadata.get("name") or relpath or f"magnet-{magnet_id}" - self._emit_local_file( - downloaded_path=path, - source=file_url or f"alldebrid:magnet:{magnet_id}", - title_hint=title_hint, - tags_hint=None, - media_kind_hint="file", - full_metadata=metadata, - progress=progress, - config=config, - provider_hint=provider_name, - ) - - try: - downloaded_extra = provider_obj.download_items( - sr, - final_output_dir, - emit=_on_emit, - progress=progress, - quiet_mode=quiet_mode, - path_from_result=coerce_to_path, - config=config, - ) - except TypeError: - downloaded_extra = provider_obj.download_items( - sr, - final_output_dir, - emit=_on_emit, - progress=progress, - quiet_mode=quiet_mode, - path_from_result=coerce_to_path, - ) - except Exception as exc: - log(f"[download-file] failed to download magnet {magnet_id}: {exc}", file=sys.stderr) - return 1 - - if downloaded_extra: - debug(f"[download-file] AllDebrid magnet {magnet_id} emitted {downloaded_extra} files") - return 0 - - log( - f"[download-file] AllDebrid magnet {magnet_id} produced no downloads", - file=sys.stderr, - ) - return 1 - def _maybe_show_provider_picker( self, *, diff --git a/cmdlet/get_url.py b/cmdlet/get_url.py index 009fdb3..78b9002 100644 --- a/cmdlet/get_url.py +++ b/cmdlet/get_url.py @@ -75,6 +75,17 @@ class Get_Url(Cmdlet): return url.lower() + @staticmethod + def _looks_like_url_pattern(value: str) -> bool: + v = str(value or "").strip().lower() + if not v: + return False + if "://" in v: + return True + if v.startswith(("magnet:", "torrent:", "ytdl:", "tidal:", "ftp:", "sftp:", "file:")): + return True + return "." in v and "/" in v + @staticmethod def _match_url_pattern(url: str, pattern: str) -> bool: """Match URL against pattern with wildcard support. @@ -82,10 +93,14 @@ class Get_Url(Cmdlet): Strips protocol/www from both URL and pattern before matching. Supports * and ? wildcards. """ + raw_pattern = str(pattern or "").strip() normalized_url = Get_Url._normalize_url_for_search(url) - normalized_pattern = Get_Url._normalize_url_for_search(pattern) + normalized_pattern = Get_Url._normalize_url_for_search(raw_pattern) - has_wildcards = any(ch in normalized_pattern for ch in ("*", "?")) + looks_like_url = Get_Url._looks_like_url_pattern(raw_pattern) + has_wildcards = "*" in normalized_pattern or ( + not looks_like_url and "?" in normalized_pattern + ) if has_wildcards: return fnmatch(normalized_url, normalized_pattern) @@ -324,25 +339,58 @@ class Get_Url(Cmdlet): # This avoids the expensive/incorrect "search('*')" scan. try: raw_pattern = str(pattern or "").strip() - has_wildcards = any(ch in raw_pattern for ch in ("*", "?")) + looks_like_url = self._looks_like_url_pattern(raw_pattern) + has_wildcards = "*" in raw_pattern or ( + not looks_like_url and "?" in raw_pattern + ) # If this is a Hydrus backend and the pattern is a single URL, # normalize it through the official API. Skip for bare domains. normalized_url = None - looks_like_url = ( - "://" in raw_pattern or raw_pattern.startswith("magnet:") - ) - if not has_wildcards and looks_like_url and hasattr(backend, "get_url_info"): - try: - info = backend.get_url_info(raw_pattern) # type: ignore[attr-defined] - if isinstance(info, dict): - norm = info.get("normalised_url") or info.get("normalized_url") - if isinstance(norm, str) and norm.strip(): - normalized_url = norm.strip() - except Exception: - normalized_url = None + normalized_search_pattern = None + if not has_wildcards and looks_like_url: + normalized_search_pattern = self._normalize_url_for_search( + raw_pattern + ) + if ( + normalized_search_pattern + and normalized_search_pattern != raw_pattern + ): + debug( + "get-url normalized raw pattern: %s -> %s", + raw_pattern, + normalized_search_pattern, + ) + if hasattr(backend, "get_url_info"): + try: + info = backend.get_url_info(raw_pattern) # type: ignore[attr-defined] + if isinstance(info, dict): + norm = ( + info.get("normalised_url") + or info.get("normalized_url") + ) + if isinstance(norm, str) and norm.strip(): + normalized_url = self._normalize_url_for_search( + norm.strip() + ) + except Exception: + pass + if ( + normalized_url + and normalized_url != normalized_search_pattern + and normalized_url != raw_pattern + ): + debug( + "get-url normalized backend result: %s -> %s", + raw_pattern, + normalized_url, + ) - target_pattern = normalized_url or raw_pattern + target_pattern = ( + normalized_url + or normalized_search_pattern + or raw_pattern + ) if has_wildcards or not target_pattern: search_query = "url:*" else: diff --git a/tool/ytdlp.py b/tool/ytdlp.py index b458805..4e28e65 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -324,7 +324,7 @@ def format_for_table_selection( This helper formats a single format from list_formats() into the shape expected by the ResultTable system, ready for user selection and routing - to download-file with -format argument. + to download-file with -query "format:". Args: fmt: Format dict from yt-dlp @@ -403,9 +403,9 @@ def format_for_table_selection( "format_id": format_id, "url": url, "item_selector": selection_format_id, - "_selection_args": ["-format", selection_format_id], + "_selection_args": ["-query", f"format:{selection_format_id}"], }, - "_selection_args": ["-format", selection_format_id], + "_selection_args": ["-query", f"format:{selection_format_id}"], }