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})" "(filespace\\.com/[a-zA-Z0-9]{12})"
], ],
"regexp": "(filespace\\.com/fd/([a-zA-Z0-9]{12}))|((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": { "filezip": {
"name": "filezip", "name": "filezip",
@@ -595,7 +595,7 @@
"(simfileshare\\.net/download/[0-9]+/)" "(simfileshare\\.net/download/[0-9]+/)"
], ],
"regexp": "(simfileshare\\.net/download/[0-9]+/)", "regexp": "(simfileshare\\.net/download/[0-9]+/)",
"status": false "status": true
}, },
"streamtape": { "streamtape": {
"name": "streamtape", "name": "streamtape",

View File

@@ -1876,6 +1876,212 @@ local function _download_url_for_current_item(url)
return base, true return base, true
end 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) local function _normalize_url_for_store_lookup(url)
url = trim(tostring(url or '')) url = trim(tostring(url or ''))
if url == '' then if url == '' then
@@ -3742,6 +3948,8 @@ function M._apply_web_subtitle_load_defaults(reason)
return false return false
end end
M._prepare_ytdl_format_for_web_load(target, reason or 'on-load')
local raw = M._build_web_ytdl_raw_options() local raw = M._build_web_ytdl_raw_options()
if raw and raw ~= '' then if raw and raw ~= '' then
pcall(mp.set_property, 'file-local-options/ytdl-raw-options', raw) pcall(mp.set_property, 'file-local-options/ytdl-raw-options', raw)
@@ -4664,22 +4872,35 @@ _show_format_list_osd = function(items, max_items)
end end
local function _current_ytdl_format_string() 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. -- 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 ''))
if fmt ~= '' then local suspicious_reason = M._suspicious_ytdl_format_reason(fmt, url, raw)
if fmt ~= '' and not suspicious_reason then
return fmt return fmt
elseif fmt ~= '' then
_lua_log('ytdl-format: ignoring suspicious current format source=ytdl-format value=' .. tostring(fmt) .. ' reason=' .. tostring(suspicious_reason))
end end
-- Fallbacks: option value, or raw info if available. -- Fallbacks: option value, or raw info if available.
local opt = trim(tostring(mp.get_property('options/ytdl-format') or '')) 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 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 end
local raw = mp.get_property_native('ytdl-raw-info')
if type(raw) == 'table' then if type(raw) == 'table' then
if raw.format_id and tostring(raw.format_id) ~= '' 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 end
local rf = raw.requested_formats local rf = raw.requested_formats
if type(rf) == 'table' then if type(rf) == 'table' then
@@ -4690,7 +4911,12 @@ local function _current_ytdl_format_string()
end end
end end
if #parts >= 1 then 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 end
end end
@@ -4957,6 +5183,16 @@ local function _apply_ytdl_format_and_reload(url, fmt)
return return
end 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 pos = mp.get_property_number('time-pos')
local paused = mp.get_property_native('pause') and true or false local paused = mp.get_property_native('pause') and true or false
local media_title = trim(tostring(mp.get_property('media-title') or '')) 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: try:
if target_client.sock is None: if target_client.sock is None:
if use_shared_ipc_client: 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 return False
if not target_client.connect(): if not target_client.connect():
_append_helper_log( _append_helper_log(

View File

@@ -4,6 +4,7 @@ import re
import sys import sys
import tempfile import tempfile
import shutil import shutil
from collections import deque
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
@@ -1094,78 +1095,13 @@ class HydrusNetwork(Store):
return token.replace("*", "").replace("?", "") return token.replace("*", "").replace("?", "")
# Fast-path: exact URL via /add_urls/get_url_files when a full URL is provided. # Fast-path: exact URL via /add_urls/get_url_files when a full URL is provided.
exact_url_attempted = False
try: try:
if pattern.startswith("http://") or pattern.startswith( if pattern.startswith("http://") or pattern.startswith("https://"):
"https://"): exact_url_attempted = True
from API.HydrusNetwork import HydrusRequestSpec metadata_list = self.lookup_url_metadata(pattern, minimal=minimal)
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)
]
except Exception: except Exception:
metadata_list = None metadata_list = [] if exact_url_attempted else None
# Fallback: substring scan # Fallback: substring scan
if metadata_list is None: if metadata_list is None:
@@ -2108,6 +2044,115 @@ class HydrusNetwork(Store):
debug(f"{self._log_prefix()} get_url failed: {exc}") debug(f"{self._log_prefix()} get_url failed: {exc}")
return [] 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: def get_url_info(self, url: str, **kwargs: Any) -> dict[str, Any] | None:
"""Return Hydrus URL info for a single URL (Hydrus-only helper). """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 is_storage_backend_location = False
if location: if location:
try: try:
# Check against the cached startup list of available backends store_for_lookup = storage_registry or Store(config)
from cmdlet._shared import SharedArgs is_storage_backend_location = Add_File._resolve_backend_by_name(store_for_lookup, str(location)) is not None
available_backends = SharedArgs.get_store_choices(config)
is_storage_backend_location = location in available_backends
except Exception: except Exception:
is_storage_backend_location = False is_storage_backend_location = False
@@ -608,8 +606,8 @@ class Add_File(Cmdlet):
if location: if location:
try: try:
store = storage_registry or Store(config) store = storage_registry or Store(config)
backends = store.list_backends() resolved_backend = Add_File._resolve_backend_by_name(store, str(location))
if location in backends: if resolved_backend is not None:
code = self._handle_storage_backend( code = self._handle_storage_backend(
item, item,
media_path, media_path,

View File

@@ -2685,6 +2685,14 @@ class Download_File(Cmdlet):
pass pass
try: 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 [] hits = backend.search(f"url:{canonical_url}", limit=5) or []
except Exception: except Exception:
hits = [] 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, def _build_ytdl_options(config: Optional[Dict[str,
Any]], Any]],
hydrus_header: Optional[str]) -> Optional[str]: 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] = [] 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: if hydrus_header:
opts.append(f"add-header={hydrus_header}") opts.append(f"add-header={hydrus_header}")
return ",".join(opts) if opts else None return ",".join(opts) if opts else None
@@ -2584,19 +2577,10 @@ def _start_mpv(
_schedule_notes_prefetch(items[:1], config) _schedule_notes_prefetch(items[:1], config)
cookies_path = None if ytdl_opts:
try: debug(f"Starting MPV with ytdl raw options: {ytdl_opts}")
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('\\', '/')}")
else: else:
debug("Starting MPV with browser cookies: chrome") debug("Starting MPV with default yt-dlp raw options")
try: try:
extra_args: List[str] = [ extra_args: List[str] = [