your commit message

This commit is contained in:
2026-02-25 17:35:38 -08:00
parent 39a84b3274
commit 834be06ab9
12 changed files with 517 additions and 543 deletions

View File

@@ -92,7 +92,7 @@
"(hitfile\\.net/[a-z0-9A-Z]{4,9})" "(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}))", "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": false "status": true
}, },
"mega": { "mega": {
"name": "mega", "name": "mega",
@@ -482,7 +482,7 @@
"(katfile\\.com/[0-9a-zA-Z]{12})" "(katfile\\.com/[0-9a-zA-Z]{12})"
], ],
"regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", "regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"status": false "status": true
}, },
"mediafire": { "mediafire": {
"name": "mediafire", "name": "mediafire",
@@ -690,7 +690,7 @@
"uploadrar\\.(net|com)/([0-9a-z]{12})" "uploadrar\\.(net|com)/([0-9a-z]{12})"
], ],
"regexp": "((get|cloud)\\.rahim-soft\\.com/([0-9a-z]{12}))|((fingau\\.com/([0-9a-z]{12})))|((tech|miui|cloud|flash)\\.getpczone\\.com/([0-9a-z]{12}))|(miui.rahim-soft\\.com/([0-9a-z]{12}))|(uploadrar\\.(net|com)/([0-9a-z]{12}))", "regexp": "((get|cloud)\\.rahim-soft\\.com/([0-9a-z]{12}))|((fingau\\.com/([0-9a-z]{12})))|((tech|miui|cloud|flash)\\.getpczone\\.com/([0-9a-z]{12}))|(miui.rahim-soft\\.com/([0-9a-z]{12}))|(uploadrar\\.(net|com)/([0-9a-z]{12}))",
"status": false, "status": true,
"hardRedirect": [ "hardRedirect": [
"uploadrar.com/([0-9a-zA-Z]{12})" "uploadrar.com/([0-9a-zA-Z]{12})"
] ]
@@ -775,7 +775,7 @@
"(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})" "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})"
], ],
"regexp": "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})", "regexp": "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})",
"status": false "status": true
} }
}, },
"streams": { "streams": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
# Medeia MPV script options # Medeia MPV script options
store=rpi store=

View File

@@ -6,7 +6,7 @@ import subprocess
import time import time
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from API.Tidal import ( from API.Tidal import (
@@ -457,15 +457,32 @@ class Tidal(Provider):
if idx >= len(parts): if idx >= len(parts):
return "", None return "", None
view = parts[idx].lower() # Scan ALL (view, id) pairs in the path, e.g.
if view not in {"album", "track", "artist"}: # /album/634516/track/634519 → [("album", 634516), ("track", 634519)]
# When multiple views are present, prefer the more specific one:
# track > album > artist
_VIEW_PRIORITY = {"track": 2, "album": 1, "artist": 0}
_VALID_VIEWS = set(_VIEW_PRIORITY)
found: dict[str, int] = {}
i = idx
while i < len(parts):
v = parts[i].lower()
if v in _VALID_VIEWS:
# Look ahead for the first integer following this view keyword
for j in range(i + 1, len(parts)):
cand = self._parse_int(parts[j])
if cand is not None:
found[v] = cand
i = j # advance past the id
break
i += 1
if not found:
return "", None return "", None
for segment in parts[idx + 1:]: # Return the highest-priority view that was found
identifier = self._parse_int(segment) best_view = max(found, key=lambda v: _VIEW_PRIORITY.get(v, -1))
if identifier is not None: return best_view, found[best_view]
return view, identifier
return view, None
def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult: def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult:
if isinstance(detail, dict): if isinstance(detail, dict):
@@ -700,7 +717,9 @@ class Tidal(Provider):
def _tracks_for_album(self, *, album_id: Optional[int], album_title: str, artist_name: str = "", limit: int = 200) -> List[SearchResult]: def _tracks_for_album(self, *, album_id: Optional[int], album_title: str, artist_name: str = "", limit: int = 200) -> List[SearchResult]:
title = str(album_title or "").strip() title = str(album_title or "").strip()
if not title: # When album_id is provided the /album/ endpoint can resolve tracks directly —
# no title is required. Only bail out early when we have neither.
if not title and not album_id:
return [] return []
def _norm_album(text: str) -> str: def _norm_album(text: str) -> str:
@@ -1351,6 +1370,9 @@ class Tidal(Provider):
subtitles = lyrics.get("subtitles") subtitles = lyrics.get("subtitles")
if isinstance(subtitles, str) and subtitles.strip(): if isinstance(subtitles, str) and subtitles.strip():
md["_tidal_lyrics_subtitles"] = subtitles.strip() md["_tidal_lyrics_subtitles"] = subtitles.strip()
# Generic key consumed by download-file._emit_local_file to
# persist lyrics as a store note without provider-specific logic.
md["_notes"] = {"lyric": subtitles.strip()}
# Ensure downstream cmdlets see our enriched metadata. # Ensure downstream cmdlets see our enriched metadata.
try: try:
@@ -1523,6 +1545,19 @@ class Tidal(Provider):
if not identifier: if not identifier:
return False, None return False, None
# In download-file flows, return a provider action so the cmdlet can
# invoke this provider's bulk download hook and emit each track.
if output_dir is not None:
return True, {
"action": "download_items",
"path": f"tidal://album/{identifier}",
"title": f"Album {identifier}",
"metadata": {
"album_id": identifier,
},
"media_kind": "audio",
}
try: try:
track_results = self._tracks_for_album( track_results = self._tracks_for_album(
album_id=identifier, album_id=identifier,
@@ -1562,6 +1597,76 @@ class Tidal(Provider):
return False, None return False, None
def download_items(
self,
result: SearchResult,
output_dir: Path,
*,
emit: Callable[[Path, str, str, Dict[str, Any]], None],
progress: Any,
quiet_mode: bool,
path_from_result: Callable[[Any], Path],
config: Optional[Dict[str, Any]] = None,
) -> int:
_ = progress
_ = quiet_mode
_ = path_from_result
_ = config
metadata = getattr(result, "full_metadata", None)
md: Dict[str, Any] = dict(metadata) if isinstance(metadata, dict) else {}
album_id = self._parse_int(md.get("album_id") or md.get("albumId") or md.get("id"))
album_title = stringify(md.get("album_title") or md.get("title") or md.get("album"))
artist_name = stringify(md.get("artist_name") or md.get("_artist_name") or md.get("artist"))
if not artist_name:
artist_obj = md.get("artist")
if isinstance(artist_obj, dict):
artist_name = stringify(artist_obj.get("name"))
path_text = stringify(getattr(result, "path", ""))
if path_text:
view, identifier = self._parse_tidal_url(path_text)
if view == "album" and not album_id:
album_id = identifier
if not album_id:
return 0
try:
track_results = self._tracks_for_album(
album_id=album_id,
album_title=album_title,
artist_name=artist_name,
limit=500,
)
except Exception:
return 0
if not track_results:
return 0
downloaded_count = 0
for track_result in track_results:
try:
downloaded = self.download(track_result, output_dir)
except Exception:
downloaded = None
if not downloaded:
continue
tr_md_raw = getattr(track_result, "full_metadata", None)
tr_md = dict(tr_md_raw) if isinstance(tr_md_raw, dict) else {}
source = stringify(tr_md.get("url") or getattr(track_result, "path", ""))
relpath = str(downloaded.name)
emit(downloaded, source, relpath, tr_md)
downloaded_count += 1
return downloaded_count
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]: def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
base = base_url.rstrip("/") base = base_url.rstrip("/")
for client in self.api_clients: for client in self.api_clients:

View File

@@ -1895,7 +1895,7 @@ class PipelineExecutor:
if row_args: if row_args:
selected_row_args.extend(row_args) selected_row_args.extend(row_args)
if selected_row_args: if selected_row_args and not stages:
if isinstance(source_cmd, list): if isinstance(source_cmd, list):
cmd_list: List[str] = [str(x) for x in source_cmd if x is not None] cmd_list: List[str] = [str(x) for x in source_cmd if x is not None]
elif isinstance(source_cmd, str): elif isinstance(source_cmd, str):
@@ -1914,11 +1914,7 @@ class PipelineExecutor:
# as the positional URL and avoid this class of parsing errors. # as the positional URL and avoid this class of parsing errors.
expanded_stage: List[str] = cmd_list + selected_row_args + source_args expanded_stage: List[str] = cmd_list + selected_row_args + source_args
if first_stage_had_extra_args and stages: stages.insert(0, expanded_stage)
expanded_stage += stages[0]
stages[0] = expanded_stage
else:
stages.insert(0, expanded_stage)
if pipeline_session and worker_manager: if pipeline_session and worker_manager:
try: try:
@@ -1928,6 +1924,8 @@ class PipelineExecutor:
) )
except Exception: except Exception:
logger.exception("Failed to record pipeline log step for @N expansion (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None)) logger.exception("Failed to record pipeline log step for @N expansion (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None))
elif selected_row_args and stages:
debug("@N: skipping source command expansion because downstream stages exist")
stage_table = None stage_table = None
try: try:

View File

@@ -87,10 +87,9 @@ def _load_root_modules() -> None:
def _load_helper_modules() -> None: def _load_helper_modules() -> None:
try: # Provider-specific module pre-loading removed; providers are loaded lazily
import API.alldebrid as _alldebrid # through ProviderCore.registry when first referenced.
except Exception: pass
pass
def _register_native_commands() -> None: def _register_native_commands() -> None:

View File

@@ -965,6 +965,48 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]:
return text return text
def resolve_hash_for_cmdlet(
raw_hash: Optional[str],
raw_path: Optional[str],
override_hash: Optional[str],
) -> Optional[str]:
"""Resolve a file hash for note/tag/file cmdlets.
Shared implementation used by add-note, delete-note, get-note, and similar
cmdlets that need to identify a file by its SHA-256 hash.
Resolution order:
1. ``override_hash`` — explicit hash provided via *-query* (highest priority)
2. ``raw_hash`` — positional hash argument
3. ``raw_path`` stem — if the filename stem is a 64-char hex string it is
treated directly as the hash (Hydrus-style naming convention)
4. SHA-256 computed from the file at ``raw_path``
Args:
raw_hash: Hash string from positional argument.
raw_path: Filesystem path to the file (may be None).
override_hash: Hash extracted from *-query* (takes precedence).
Returns:
Normalised 64-char lowercase hex hash, or ``None`` if unresolvable.
"""
resolved = normalize_hash(override_hash) if override_hash else normalize_hash(raw_hash)
if resolved:
return resolved
if raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
return stem.lower()
if p.exists() and p.is_file():
from SYS.utils import sha256_file as _sha256_file
return _sha256_file(p)
except Exception:
return None
return None
def parse_hash_query(query: Optional[str]) -> List[str]: def parse_hash_query(query: Optional[str]) -> List[str]:
"""Parse a unified query string for `hash:` into normalized SHA256 hashes. """Parse a unified query string for `hash:` into normalized SHA256 hashes.

View File

@@ -44,6 +44,13 @@ SUPPORTED_MEDIA_EXTENSIONS = ALL_SUPPORTED_EXTENSIONS
DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256 DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256
# Protocol schemes that identify a remote resource / not a local file path.
# Used by multiple methods in this file to guard against URL strings being
# treated as local file paths.
_REMOTE_URL_PREFIXES: tuple[str, ...] = (
"http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:",
)
def _truncate_debug_note_text(value: Any) -> str: def _truncate_debug_note_text(value: Any) -> str:
raw = str(value or "") raw = str(value or "")
@@ -1203,7 +1210,7 @@ class Add_File(Cmdlet):
if candidate: if candidate:
s = str(candidate).lower() s = str(candidate).lower()
if s.startswith(("http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:")): if s.startswith(_REMOTE_URL_PREFIXES):
log("add-file ingests local files only. Use download-file first.", file=sys.stderr) log("add-file ingests local files only. Use download-file first.", file=sys.stderr)
return None, None, None return None, None, None
@@ -1427,7 +1434,7 @@ class Add_File(Cmdlet):
if not val: if not val:
return False return False
# Obvious schemes # Obvious schemes
if val.startswith(("http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:")): if val.startswith(_REMOTE_URL_PREFIXES):
return True return True
# Domain-like patterns or local file paths (but we want URLs here) # Domain-like patterns or local file paths (but we want URLs here)
if "://" in val: if "://" in val:

View File

@@ -175,25 +175,9 @@ class Add_Note(Cmdlet):
self, self,
raw_hash: Optional[str], raw_hash: Optional[str],
raw_path: Optional[str], raw_path: Optional[str],
override_hash: Optional[str] override_hash: Optional[str],
) -> Optional[str]: ) -> Optional[str]:
resolved = normalize_hash(override_hash return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
) if override_hash else normalize_hash(raw_hash)
if resolved:
return resolved
if raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef"
for c in stem.lower()):
return stem.lower()
if p.exists() and p.is_file():
return sha256_file(p)
except Exception:
return None
return None
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if should_show_help(args): if should_show_help(args):

View File

@@ -54,24 +54,9 @@ class Delete_Note(Cmdlet):
self, self,
raw_hash: Optional[str], raw_hash: Optional[str],
raw_path: Optional[str], raw_path: Optional[str],
override_hash: Optional[str] override_hash: Optional[str],
) -> Optional[str]: ) -> Optional[str]:
resolved = normalize_hash(override_hash return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
) if override_hash else normalize_hash(raw_hash)
if resolved:
return resolved
if raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef"
for c in stem.lower()):
return stem.lower()
if p.exists() and p.is_file():
return sha256_file(p)
except Exception:
return None
return None
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if should_show_help(args): if should_show_help(args):

View File

@@ -54,6 +54,10 @@ resolve_target_dir = sh.resolve_target_dir
coerce_to_path = sh.coerce_to_path coerce_to_path = sh.coerce_to_path
build_pipeline_preview = sh.build_pipeline_preview build_pipeline_preview = sh.build_pipeline_preview
# URI scheme prefixes owned by AllDebrid (magic-link and emoji shorthand).
# Defined once here so every method in this file references the same constant.
_ALLDEBRID_PREFIXES: tuple[str, ...] = ("alldebrid:", "alldebrid🧲")
class Download_File(Cmdlet): class Download_File(Cmdlet):
"""Class-based download-file cmdlet - direct HTTP downloads.""" """Class-based download-file cmdlet - direct HTTP downloads."""
@@ -652,9 +656,12 @@ class Download_File(Cmdlet):
notes: Optional[Dict[str, str]] = None notes: Optional[Dict[str, str]] = None
try: try:
if isinstance(full_metadata, dict): if isinstance(full_metadata, dict):
subtitles = full_metadata.get("_tidal_lyrics_subtitles") # Providers attach pre-built notes under the generic "_notes" key
if isinstance(subtitles, str) and subtitles.strip(): # (e.g. Tidal sets {"lyric": subtitles} during download enrichment).
notes = {"lyric": subtitles} # This keeps provider-specific metadata handling inside the provider.
_provider_notes = full_metadata.get("_notes")
if isinstance(_provider_notes, dict) and _provider_notes:
notes = {str(k): str(v) for k, v in _provider_notes.items() if k and v}
except Exception: except Exception:
notes = None notes = None
tag: List[str] = [] tag: List[str] = []
@@ -2787,7 +2794,9 @@ class Download_File(Cmdlet):
s_val = str(value or "").strip().lower() s_val = str(value or "").strip().lower()
except Exception: except Exception:
return False return False
return s_val.startswith(("http://", "https://", "magnet:", "torrent:", "alldebrid:", "alldebrid🧲")) return s_val.startswith(
("http://", "https://", "magnet:", "torrent:") + _ALLDEBRID_PREFIXES
)
def _extract_selection_args(item: Any) -> tuple[Optional[List[str]], Optional[str]]: def _extract_selection_args(item: Any) -> tuple[Optional[List[str]], Optional[str]]:
selection_args: Optional[List[str]] = None selection_args: Optional[List[str]] = None
@@ -2955,15 +2964,13 @@ class Download_File(Cmdlet):
and (not parsed.get("path"))): and (not parsed.get("path"))):
candidate = str(raw_url[0] or "").strip() candidate = str(raw_url[0] or "").strip()
low = candidate.lower() low = candidate.lower()
looks_like_url = low.startswith(( looks_like_url = low.startswith(
"http://", "https://", "ftp://", "magnet:", "torrent:", ("http://", "https://", "ftp://", "magnet:", "torrent:") + _ALLDEBRID_PREFIXES
"alldebrid:", "alldebrid🧲" )
))
looks_like_provider = ( looks_like_provider = (
":" in candidate and not candidate.startswith(( ":" in candidate and not candidate.startswith(
"http:", "https:", "ftp:", "ftps:", "file:", ("http:", "https:", "ftp:", "ftps:", "file:") + _ALLDEBRID_PREFIXES
"alldebrid:" )
))
) )
looks_like_windows_path = ( looks_like_windows_path = (
(len(candidate) >= 2 and candidate[1] == ":") (len(candidate) >= 2 and candidate[1] == ":")

View File

@@ -49,24 +49,9 @@ class Get_Note(Cmdlet):
self, self,
raw_hash: Optional[str], raw_hash: Optional[str],
raw_path: Optional[str], raw_path: Optional[str],
override_hash: Optional[str] override_hash: Optional[str],
) -> Optional[str]: ) -> Optional[str]:
resolved = normalize_hash(override_hash return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
) if override_hash else normalize_hash(raw_hash)
if resolved:
return resolved
if raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef"
for c in stem.lower()):
return stem.lower()
if p.exists() and p.is_file():
return sha256_file(p)
except Exception:
return None
return None
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if should_show_help(args): if should_show_help(args):