diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 7123815..66708bf 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -375,7 +375,7 @@ "(filespace\\.com/[a-zA-Z0-9]{12})" ], "regexp": "(filespace\\.com/fd/([a-zA-Z0-9]{12}))|((filespace\\.com/[a-zA-Z0-9]{12}))", - "status": false + "status": true }, "filezip": { "name": "filezip", @@ -595,7 +595,7 @@ "(simfileshare\\.net/download/[0-9]+/)" ], "regexp": "(simfileshare\\.net/download/[0-9]+/)", - "status": false + "status": true }, "streamtape": { "name": "streamtape", diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index a1bf680..55bc09d 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -1876,6 +1876,212 @@ local function _download_url_for_current_item(url) return base, true end +function M._extract_youtube_video_id(url) + url = trim(tostring(url or '')) + if url == '' then + return nil + end + + local lower = url:lower() + local video_id = nil + if lower:match('youtu%.be/') then + video_id = url:match('youtu%.be/([^%?&#/]+)') + elseif lower:match('youtube%.com/watch') or lower:match('youtube%-nocookie%.com/watch') then + video_id = _extract_query_param(url, 'v') + elseif lower:match('youtube%.com/shorts/') or lower:match('youtube%-nocookie%.com/shorts/') then + video_id = url:match('/shorts/([^%?&#/]+)') + elseif lower:match('youtube%.com/live/') or lower:match('youtube%-nocookie%.com/live/') then + video_id = url:match('/live/([^%?&#/]+)') + elseif lower:match('youtube%.com/embed/') or lower:match('youtube%-nocookie%.com/embed/') then + video_id = url:match('/embed/([^%?&#/]+)') + end + + video_id = trim(tostring(video_id or '')) + if video_id == '' or not video_id:match('^[%w_-]+$') then + return nil + end + return video_id +end + +function M._suspicious_ytdl_format_reason(fmt, url, raw) + fmt = trim(tostring(fmt or '')) + url = trim(tostring(url or '')) + if fmt == '' then + return nil + end + + local lower_fmt = fmt:lower() + if lower_fmt:match('^https?://') or lower_fmt:match('^rtmp') or (url ~= '' and fmt == url) then + return 'format string is a url' + end + + local youtube_id = M._extract_youtube_video_id(url) + if youtube_id and fmt == youtube_id then + return 'format matches current youtube video id' + end + + if type(raw) == 'table' and youtube_id then + local raw_id = trim(tostring(raw.id or '')) + if raw_id ~= '' and raw_id == youtube_id and fmt == raw_id then + return 'format matches raw youtube video id' + end + end + + return nil +end + +function M._clear_suspicious_ytdl_format_for_url(url, reason) + url = trim(tostring(url or '')) + if url == '' then + return false + end + + local raw = mp.get_property_native('ytdl-raw-info') + local bad_props = {} + local bad_value = nil + local bad_reason = nil + local checks = { + { + prop = 'ytdl-format', + value = mp.get_property_native('ytdl-format'), + }, + { + prop = 'file-local-options/ytdl-format', + value = mp.get_property('file-local-options/ytdl-format'), + }, + { + prop = 'options/ytdl-format', + value = mp.get_property('options/ytdl-format'), + }, + } + + for _, item in ipairs(checks) do + local candidate = trim(tostring(item.value or '')) + local why = M._suspicious_ytdl_format_reason(candidate, url, raw) + if why then + if not bad_value then + bad_value = candidate + end + if not bad_reason then + bad_reason = why + end + bad_props[#bad_props + 1] = tostring(item.prop) + end + end + + if #bad_props == 0 then + return false + end + + pcall(mp.set_property, 'options/ytdl-format', '') + pcall(mp.set_property, 'file-local-options/ytdl-format', '') + pcall(mp.set_property, 'ytdl-format', '') + _lua_log( + 'ytdl-format: cleared suspicious selector=' .. tostring(bad_value or '') + .. ' props=' .. table.concat(bad_props, ',') + .. ' reason=' .. tostring(bad_reason or reason or 'invalid') + .. ' url=' .. tostring(url) + ) + return true +end + +function M._prepare_ytdl_format_for_web_load(url, reason) + url = trim(tostring(url or '')) + if url == '' then + return false + end + + if M._clear_suspicious_ytdl_format_for_url(url, reason) then + return true + end + + local normalized_url = url:gsub('#.*$', '') + local base, query = normalized_url:match('^([^?]+)%?(.*)$') + if base then + local kept = {} + for pair in query:gmatch('[^&]+') do + local raw_key = pair:match('^([^=]+)') or pair + local key = tostring(_percent_decode(raw_key) or raw_key or ''):lower() + local keep = true + if key == 't' or key == 'start' or key == 'time_continue' or key == 'timestamp' or key == 'time' or key == 'begin' then + keep = false + elseif key:match('^utm_') then + keep = false + end + if keep then + kept[#kept + 1] = pair + end + end + if #kept > 0 then + normalized_url = base .. '?' .. table.concat(kept, '&') + else + normalized_url = base + end + end + local explicit_reload_url = normalized_url + explicit_reload_url = explicit_reload_url:gsub('^[%a][%w+%.%-]*://', '') + explicit_reload_url = explicit_reload_url:gsub('^www%.', '') + explicit_reload_url = explicit_reload_url:gsub('/+$', '') + explicit_reload_url = explicit_reload_url:lower() + + local is_explicit_reload = ( + explicit_reload_url ~= '' + and _skip_next_store_check_url ~= '' + and explicit_reload_url == _skip_next_store_check_url + ) + + local active_props = {} + local first_value = nil + local checks = { + { + prop = 'ytdl-format', + value = mp.get_property_native('ytdl-format'), + }, + { + prop = 'file-local-options/ytdl-format', + value = mp.get_property('file-local-options/ytdl-format'), + }, + { + prop = 'options/ytdl-format', + value = mp.get_property('options/ytdl-format'), + }, + } + + for _, item in ipairs(checks) do + local candidate = trim(tostring(item.value or '')) + if candidate ~= '' then + if not first_value then + first_value = candidate + end + active_props[#active_props + 1] = tostring(item.prop) .. '=' .. candidate + end + end + + if #active_props == 0 then + return false + end + + if is_explicit_reload then + _lua_log( + 'ytdl-format: preserving explicit reload selector reason=' .. tostring(reason or 'on-load') + .. ' url=' .. tostring(url) + .. ' values=' .. table.concat(active_props, '; ') + ) + return false + end + + pcall(mp.set_property, 'options/ytdl-format', '') + pcall(mp.set_property, 'file-local-options/ytdl-format', '') + pcall(mp.set_property, 'ytdl-format', '') + _lua_log( + 'ytdl-format: cleared stale selector=' .. tostring(first_value or '') + .. ' reason=' .. tostring(reason or 'on-load') + .. ' url=' .. tostring(url) + .. ' values=' .. table.concat(active_props, '; ') + ) + return true +end + local function _normalize_url_for_store_lookup(url) url = trim(tostring(url or '')) if url == '' then @@ -3742,6 +3948,8 @@ function M._apply_web_subtitle_load_defaults(reason) return false end + M._prepare_ytdl_format_for_web_load(target, reason or 'on-load') + local raw = M._build_web_ytdl_raw_options() if raw and raw ~= '' then pcall(mp.set_property, 'file-local-options/ytdl-raw-options', raw) @@ -4664,22 +4872,35 @@ _show_format_list_osd = function(items, max_items) end local function _current_ytdl_format_string() + local url = _current_url_for_web_actions() or _current_target() or '' + local raw = mp.get_property_native('ytdl-raw-info') + -- Preferred: mpv exposes the active ytdl format string. local fmt = trim(tostring(mp.get_property_native('ytdl-format') or '')) - if fmt ~= '' then + local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, raw) + if fmt ~= '' and not suspicious_reason then return fmt + elseif fmt ~= '' then + _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-format value=' .. tostring(fmt) .. ' reason=' .. tostring(suspicious_reason)) end -- Fallbacks: option value, or raw info if available. local opt = trim(tostring(mp.get_property('options/ytdl-format') or '')) - if opt ~= '' then + suspicious_reason = M._suspicious_ytdl_format_reason(opt, url, raw) + if opt ~= '' and not suspicious_reason then return opt + elseif opt ~= '' then + _lua_log('ytdl-format: ignoring suspicious current format source=options/ytdl-format value=' .. tostring(opt) .. ' reason=' .. tostring(suspicious_reason)) end - local raw = mp.get_property_native('ytdl-raw-info') if type(raw) == 'table' then if raw.format_id and tostring(raw.format_id) ~= '' then - return tostring(raw.format_id) + local raw_format_id = tostring(raw.format_id) + suspicious_reason = M._suspicious_ytdl_format_reason(raw_format_id, url, raw) + if not suspicious_reason then + return raw_format_id + end + _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.format_id value=' .. tostring(raw_format_id) .. ' reason=' .. tostring(suspicious_reason)) end local rf = raw.requested_formats if type(rf) == 'table' then @@ -4690,7 +4911,12 @@ local function _current_ytdl_format_string() end end if #parts >= 1 then - return table.concat(parts, '+') + local joined = table.concat(parts, '+') + suspicious_reason = M._suspicious_ytdl_format_reason(joined, url, raw) + if not suspicious_reason then + return joined + end + _lua_log('ytdl-format: ignoring suspicious current format source=ytdl-raw-info.requested_formats value=' .. tostring(joined) .. ' reason=' .. tostring(suspicious_reason)) end end end @@ -4957,6 +5183,16 @@ local function _apply_ytdl_format_and_reload(url, fmt) return end + local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, mp.get_property_native('ytdl-raw-info')) + if suspicious_reason then + pcall(mp.set_property, 'options/ytdl-format', '') + pcall(mp.set_property, 'file-local-options/ytdl-format', '') + pcall(mp.set_property, 'ytdl-format', '') + _lua_log('change-format: rejected suspicious format=' .. tostring(fmt) .. ' reason=' .. tostring(suspicious_reason) .. ' url=' .. tostring(url)) + mp.osd_message('Invalid format selection', 3) + return + end + local pos = mp.get_property_number('time-pos') local paused = mp.get_property_native('pause') and true or false local media_title = trim(tostring(mp.get_property('media-title') or '')) diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index af55b65..60557c2 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -1831,8 +1831,7 @@ def main(argv: Optional[list[str]] = None) -> int: try: if target_client.sock is None: if use_shared_ipc_client: - if note_ipc_unavailable is not None: - note_ipc_unavailable(f"helper-command-connect:{label or '?'}") + _note_ipc_unavailable(f"helper-command-connect:{label or '?'}") return False if not target_client.connect(): _append_helper_log( diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 2eb0f98..c3e789c 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -4,6 +4,7 @@ import re import sys import tempfile import shutil +from collections import deque from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple @@ -1094,78 +1095,13 @@ class HydrusNetwork(Store): return token.replace("*", "").replace("?", "") # Fast-path: exact URL via /add_urls/get_url_files when a full URL is provided. + exact_url_attempted = False try: - if pattern.startswith("http://") or pattern.startswith( - "https://"): - from API.HydrusNetwork import HydrusRequestSpec - - spec = HydrusRequestSpec( - method="GET", - endpoint="/add_urls/get_url_files", - query={ - "url": pattern - }, - ) - response = client._perform_request( - spec - ) # type: ignore[attr-defined] - hashes = [] - file_ids = [] - if isinstance(response, dict): - raw_hashes = response.get("hashes") or response.get( - "file_hashes" - ) - if isinstance(raw_hashes, list): - hashes = [ - str(h).strip() for h in raw_hashes - if isinstance(h, str) and str(h).strip() - ] - raw_ids = response.get("file_ids") - if isinstance(raw_ids, list): - for item in raw_ids: - try: - file_ids.append(int(item)) - except (TypeError, ValueError): - continue - - if file_ids: - payload = client.fetch_file_metadata( - file_ids=file_ids, - include_file_url=True, - include_service_keys_to_tags=not minimal, - include_duration=not minimal, - include_size=not minimal, - include_mime=not minimal, - ) - metas = ( - payload.get("metadata", - []) if isinstance(payload, - dict) else [] - ) - if isinstance(metas, list): - metadata_list = [ - m for m in metas if isinstance(m, dict) - ] - elif hashes: - payload = client.fetch_file_metadata( - hashes=hashes, - include_file_url=True, - include_service_keys_to_tags=not minimal, - include_duration=not minimal, - include_size=not minimal, - include_mime=not minimal, - ) - metas = ( - payload.get("metadata", - []) if isinstance(payload, - dict) else [] - ) - if isinstance(metas, list): - metadata_list = [ - m for m in metas if isinstance(m, dict) - ] + if pattern.startswith("http://") or pattern.startswith("https://"): + exact_url_attempted = True + metadata_list = self.lookup_url_metadata(pattern, minimal=minimal) except Exception: - metadata_list = None + metadata_list = [] if exact_url_attempted else None # Fallback: substring scan if metadata_list is None: @@ -2108,6 +2044,115 @@ class HydrusNetwork(Store): debug(f"{self._log_prefix()} get_url failed: {exc}") return [] + def lookup_url_metadata(self, url_value: str, *, minimal: bool = False) -> List[Dict[str, Any]]: + """Resolve an exact URL to Hydrus metadata using /add_urls/get_url_files variants.""" + candidate_url = str(url_value or "").strip() + if not candidate_url: + return [] + + client = self._client + if client is None: + return [] + + try: + from API.HydrusNetwork import HydrusRequestSpec, _generate_hydrus_url_variants + except Exception: + return [] + + pending: deque[str] = deque(_generate_hydrus_url_variants(candidate_url) or [candidate_url]) + seen_urls: set[str] = set() + file_ids: List[int] = [] + hashes: List[str] = [] + seen_ids: set[int] = set() + seen_hashes: set[str] = set() + + while pending: + current = str(pending.popleft() or "").strip() + if not current or current in seen_urls: + continue + seen_urls.add(current) + + try: + response = client._perform_request( + HydrusRequestSpec( + method="GET", + endpoint="/add_urls/get_url_files", + query={"url": current}, + ) + ) + except Exception: + continue + + if not isinstance(response, dict): + continue + + raw_hashes = response.get("hashes") or response.get("file_hashes") + if isinstance(raw_hashes, list): + for item in raw_hashes: + try: + file_hash = str(item or "").strip().lower() + except Exception: + continue + if not file_hash or file_hash in seen_hashes: + continue + seen_hashes.add(file_hash) + hashes.append(file_hash) + + raw_ids = response.get("file_ids") or response.get("file_id") + id_values = raw_ids if isinstance(raw_ids, list) else [raw_ids] if raw_ids is not None else [] + for item in id_values: + try: + file_id = int(item) + except (TypeError, ValueError): + continue + if file_id in seen_ids: + continue + seen_ids.add(file_id) + file_ids.append(file_id) + + for key in ("normalized_url", "redirect_url", "url"): + value = response.get(key) + if isinstance(value, str): + next_url = value.strip() + if next_url and next_url not in seen_urls: + pending.append(next_url) + + if not file_ids and not hashes: + return [] + + try: + payload = client.fetch_file_metadata( + file_ids=file_ids or None, + hashes=hashes or None, + include_file_url=True, + include_service_keys_to_tags=not minimal, + include_duration=not minimal, + include_size=not minimal, + include_mime=not minimal, + ) + except Exception: + return [] + + metadata = payload.get("metadata") if isinstance(payload, dict) else None + if not isinstance(metadata, list): + return [] + return [entry for entry in metadata if isinstance(entry, dict)] + + def find_hashes_by_url(self, url_value: str) -> List[str]: + hashes: List[str] = [] + seen: set[str] = set() + for entry in self.lookup_url_metadata(url_value, minimal=True): + raw_hash = entry.get("hash") or entry.get("hash_hex") or entry.get("file_hash") + try: + file_hash = str(raw_hash or "").strip().lower() + except Exception: + continue + if len(file_hash) != 64 or file_hash in seen: + continue + seen.add(file_hash) + hashes.append(file_hash) + return hashes + def get_url_info(self, url: str, **kwargs: Any) -> dict[str, Any] | None: """Return Hydrus URL info for a single URL (Hydrus-only helper). diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 8a5fe38..a66cda4 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -326,10 +326,8 @@ class Add_File(Cmdlet): is_storage_backend_location = False if location: try: - # Check against the cached startup list of available backends - from cmdlet._shared import SharedArgs - available_backends = SharedArgs.get_store_choices(config) - is_storage_backend_location = location in available_backends + store_for_lookup = storage_registry or Store(config) + is_storage_backend_location = Add_File._resolve_backend_by_name(store_for_lookup, str(location)) is not None except Exception: is_storage_backend_location = False @@ -608,8 +606,8 @@ class Add_File(Cmdlet): if location: try: store = storage_registry or Store(config) - backends = store.list_backends() - if location in backends: + resolved_backend = Add_File._resolve_backend_by_name(store, str(location)) + if resolved_backend is not None: code = self._handle_storage_backend( item, media_path, diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index a9dda96..f22bd5e 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -2685,6 +2685,14 @@ class Download_File(Cmdlet): pass try: + if HydrusNetwork is not None and isinstance(backend, HydrusNetwork): + hashes = backend.find_hashes_by_url(canonical_url) or [] + for existing_hash in hashes: + normalized = sh.normalize_hash(existing_hash) + if normalized: + return normalized + continue + hits = backend.search(f"url:{canonical_url}", limit=5) or [] except Exception: hits = [] diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index 9df2639..6d8c8b8 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -1000,22 +1000,15 @@ def _build_hydrus_header(config: Dict[str, Any]) -> Optional[str]: def _build_ytdl_options(config: Optional[Dict[str, Any]], hydrus_header: Optional[str]) -> Optional[str]: - """Compose ytdl-raw-options string including cookies and optional Hydrus header.""" + """Compose mpv ytdl-raw-options for playback. + + Keep this limited to options that are truly required for MPV playback. + In particular, do not pass the repo cookiefile via `cookies=...` here: + mpv's ytdl-hook can fail to resolve some YouTube/Shorts URLs when a global + cookiefile is forced, even though the same URLs work with MPV defaults. + """ opts: List[str] = [] - cookies_path = None - try: - from tool.ytdlp import YtDlpTool - cookiefile = YtDlpTool(config or {}).resolve_cookiefile() - if cookiefile is not None: - cookies_path = str(cookiefile) - except Exception: - cookies_path = None - - if cookies_path: - opts.append(f"cookies={cookies_path.replace('\\', '/')}") - # Do not force chrome cookies if none are found; let yt-dlp use its defaults or fail gracefully. - if hydrus_header: opts.append(f"add-header={hydrus_header}") return ",".join(opts) if opts else None @@ -2584,19 +2577,10 @@ def _start_mpv( _schedule_notes_prefetch(items[:1], config) - cookies_path = None - try: - from tool.ytdlp import YtDlpTool - - cookiefile = YtDlpTool(config or {}).resolve_cookiefile() - if cookiefile is not None: - cookies_path = str(cookiefile) - except Exception: - cookies_path = None - if cookies_path: - debug(f"Starting MPV with cookies file: {cookies_path.replace('\\', '/')}") + if ytdl_opts: + debug(f"Starting MPV with ytdl raw options: {ytdl_opts}") else: - debug("Starting MPV with browser cookies: chrome") + debug("Starting MPV with default yt-dlp raw options") try: extra_args: List[str] = [