your commit message
This commit is contained in:
@@ -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": false
|
||||
"status": true
|
||||
},
|
||||
"mega": {
|
||||
"name": "mega",
|
||||
@@ -482,7 +482,7 @@
|
||||
"(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": {
|
||||
"name": "mediafire",
|
||||
@@ -690,7 +690,7 @@
|
||||
"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": [
|
||||
"uploadrar.com/([0-9a-zA-Z]{12})"
|
||||
]
|
||||
@@ -775,7 +775,7 @@
|
||||
"(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})"
|
||||
],
|
||||
"regexp": "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})",
|
||||
"status": false
|
||||
"status": true
|
||||
}
|
||||
},
|
||||
"streams": {
|
||||
|
||||
716
MPV/lyric.py
716
MPV/lyric.py
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
# Medeia MPV script options
|
||||
store=rpi
|
||||
store=
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
import time
|
||||
import sys
|
||||
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 API.Tidal import (
|
||||
@@ -457,15 +457,32 @@ class Tidal(Provider):
|
||||
if idx >= len(parts):
|
||||
return "", None
|
||||
|
||||
view = parts[idx].lower()
|
||||
if view not in {"album", "track", "artist"}:
|
||||
# Scan ALL (view, id) pairs in the path, e.g.
|
||||
# /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
|
||||
|
||||
for segment in parts[idx + 1:]:
|
||||
identifier = self._parse_int(segment)
|
||||
if identifier is not None:
|
||||
return view, identifier
|
||||
return view, None
|
||||
# Return the highest-priority view that was found
|
||||
best_view = max(found, key=lambda v: _VIEW_PRIORITY.get(v, -1))
|
||||
return best_view, found[best_view]
|
||||
|
||||
def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult:
|
||||
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]:
|
||||
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 []
|
||||
|
||||
def _norm_album(text: str) -> str:
|
||||
@@ -1351,6 +1370,9 @@ class Tidal(Provider):
|
||||
subtitles = lyrics.get("subtitles")
|
||||
if isinstance(subtitles, str) and 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.
|
||||
try:
|
||||
@@ -1523,6 +1545,19 @@ class Tidal(Provider):
|
||||
if not identifier:
|
||||
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:
|
||||
track_results = self._tracks_for_album(
|
||||
album_id=identifier,
|
||||
@@ -1562,6 +1597,76 @@ class Tidal(Provider):
|
||||
|
||||
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]:
|
||||
base = base_url.rstrip("/")
|
||||
for client in self.api_clients:
|
||||
|
||||
@@ -1895,7 +1895,7 @@ class PipelineExecutor:
|
||||
if row_args:
|
||||
selected_row_args.extend(row_args)
|
||||
|
||||
if selected_row_args:
|
||||
if selected_row_args and not stages:
|
||||
if isinstance(source_cmd, list):
|
||||
cmd_list: List[str] = [str(x) for x in source_cmd if x is not None]
|
||||
elif isinstance(source_cmd, str):
|
||||
@@ -1914,10 +1914,6 @@ class PipelineExecutor:
|
||||
# as the positional URL and avoid this class of parsing errors.
|
||||
expanded_stage: List[str] = cmd_list + selected_row_args + source_args
|
||||
|
||||
if first_stage_had_extra_args and stages:
|
||||
expanded_stage += stages[0]
|
||||
stages[0] = expanded_stage
|
||||
else:
|
||||
stages.insert(0, expanded_stage)
|
||||
|
||||
if pipeline_session and worker_manager:
|
||||
@@ -1928,6 +1924,8 @@ class PipelineExecutor:
|
||||
)
|
||||
except Exception:
|
||||
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
|
||||
try:
|
||||
|
||||
@@ -87,9 +87,8 @@ def _load_root_modules() -> None:
|
||||
|
||||
|
||||
def _load_helper_modules() -> None:
|
||||
try:
|
||||
import API.alldebrid as _alldebrid
|
||||
except Exception:
|
||||
# Provider-specific module pre-loading removed; providers are loaded lazily
|
||||
# through ProviderCore.registry when first referenced.
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -965,6 +965,48 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]:
|
||||
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]:
|
||||
"""Parse a unified query string for `hash:` into normalized SHA256 hashes.
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ SUPPORTED_MEDIA_EXTENSIONS = ALL_SUPPORTED_EXTENSIONS
|
||||
|
||||
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:
|
||||
raw = str(value or "")
|
||||
@@ -1203,7 +1210,7 @@ class Add_File(Cmdlet):
|
||||
|
||||
if candidate:
|
||||
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)
|
||||
return None, None, None
|
||||
|
||||
@@ -1427,7 +1434,7 @@ class Add_File(Cmdlet):
|
||||
if not val:
|
||||
return False
|
||||
# Obvious schemes
|
||||
if val.startswith(("http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:")):
|
||||
if val.startswith(_REMOTE_URL_PREFIXES):
|
||||
return True
|
||||
# Domain-like patterns or local file paths (but we want URLs here)
|
||||
if "://" in val:
|
||||
|
||||
@@ -175,25 +175,9 @@ class Add_Note(Cmdlet):
|
||||
self,
|
||||
raw_hash: Optional[str],
|
||||
raw_path: Optional[str],
|
||||
override_hash: Optional[str]
|
||||
override_hash: Optional[str],
|
||||
) -> Optional[str]:
|
||||
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():
|
||||
return sha256_file(p)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if should_show_help(args):
|
||||
|
||||
@@ -54,24 +54,9 @@ class Delete_Note(Cmdlet):
|
||||
self,
|
||||
raw_hash: Optional[str],
|
||||
raw_path: Optional[str],
|
||||
override_hash: Optional[str]
|
||||
override_hash: Optional[str],
|
||||
) -> Optional[str]:
|
||||
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():
|
||||
return sha256_file(p)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if should_show_help(args):
|
||||
|
||||
@@ -54,6 +54,10 @@ resolve_target_dir = sh.resolve_target_dir
|
||||
coerce_to_path = sh.coerce_to_path
|
||||
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-based download-file cmdlet - direct HTTP downloads."""
|
||||
@@ -652,9 +656,12 @@ class Download_File(Cmdlet):
|
||||
notes: Optional[Dict[str, str]] = None
|
||||
try:
|
||||
if isinstance(full_metadata, dict):
|
||||
subtitles = full_metadata.get("_tidal_lyrics_subtitles")
|
||||
if isinstance(subtitles, str) and subtitles.strip():
|
||||
notes = {"lyric": subtitles}
|
||||
# Providers attach pre-built notes under the generic "_notes" key
|
||||
# (e.g. Tidal sets {"lyric": subtitles} during download enrichment).
|
||||
# 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:
|
||||
notes = None
|
||||
tag: List[str] = []
|
||||
@@ -2787,7 +2794,9 @@ class Download_File(Cmdlet):
|
||||
s_val = str(value or "").strip().lower()
|
||||
except Exception:
|
||||
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]]:
|
||||
selection_args: Optional[List[str]] = None
|
||||
@@ -2955,15 +2964,13 @@ class Download_File(Cmdlet):
|
||||
and (not parsed.get("path"))):
|
||||
candidate = str(raw_url[0] or "").strip()
|
||||
low = candidate.lower()
|
||||
looks_like_url = low.startswith((
|
||||
"http://", "https://", "ftp://", "magnet:", "torrent:",
|
||||
"alldebrid:", "alldebrid🧲"
|
||||
))
|
||||
looks_like_url = low.startswith(
|
||||
("http://", "https://", "ftp://", "magnet:", "torrent:") + _ALLDEBRID_PREFIXES
|
||||
)
|
||||
looks_like_provider = (
|
||||
":" in candidate and not candidate.startswith((
|
||||
"http:", "https:", "ftp:", "ftps:", "file:",
|
||||
"alldebrid:"
|
||||
))
|
||||
":" in candidate and not candidate.startswith(
|
||||
("http:", "https:", "ftp:", "ftps:", "file:") + _ALLDEBRID_PREFIXES
|
||||
)
|
||||
)
|
||||
looks_like_windows_path = (
|
||||
(len(candidate) >= 2 and candidate[1] == ":")
|
||||
|
||||
@@ -49,24 +49,9 @@ class Get_Note(Cmdlet):
|
||||
self,
|
||||
raw_hash: Optional[str],
|
||||
raw_path: Optional[str],
|
||||
override_hash: Optional[str]
|
||||
override_hash: Optional[str],
|
||||
) -> Optional[str]:
|
||||
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():
|
||||
return sha256_file(p)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash)
|
||||
|
||||
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if should_show_help(args):
|
||||
|
||||
Reference in New Issue
Block a user