This commit is contained in:
2026-03-31 23:30:57 -07:00
parent 6ef5b645a8
commit 57b595c1a4
7 changed files with 381 additions and 111 deletions

View File

@@ -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",

View File

@@ -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 ''))

View File

@@ -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(

View File

@@ -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).

View File

@@ -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,

View File

@@ -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 = []

View File

@@ -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] = [