df
This commit is contained in:
101
CLI.py
101
CLI.py
@@ -2556,8 +2556,11 @@ class PipelineExecutor:
|
|||||||
|
|
||||||
# Auto-insert downloader stages for provider tables.
|
# Auto-insert downloader stages for provider tables.
|
||||||
try:
|
try:
|
||||||
current_table = ctx.get_current_stage_table(
|
current_table = ctx.get_current_stage_table()
|
||||||
) or ctx.get_last_result_table()
|
if current_table is None and hasattr(ctx, "get_display_table"):
|
||||||
|
current_table = ctx.get_display_table()
|
||||||
|
if current_table is None:
|
||||||
|
current_table = ctx.get_last_result_table()
|
||||||
except Exception:
|
except Exception:
|
||||||
current_table = None
|
current_table = None
|
||||||
table_type = (
|
table_type = (
|
||||||
@@ -2584,6 +2587,9 @@ class PipelineExecutor:
|
|||||||
"libgen"}:
|
"libgen"}:
|
||||||
print("Auto-piping selection to download-file")
|
print("Auto-piping selection to download-file")
|
||||||
stages.append(["download-file"])
|
stages.append(["download-file"])
|
||||||
|
elif isinstance(table_type, str) and table_type.startswith("metadata."):
|
||||||
|
print("Auto-applying metadata selection via get-tag")
|
||||||
|
stages.append(["get-tag"])
|
||||||
else:
|
else:
|
||||||
first_cmd = stages[0][0] if stages and stages[0] else None
|
first_cmd = stages[0][0] if stages and stages[0] else None
|
||||||
if table_type == "soulseek" and first_cmd not in (
|
if table_type == "soulseek" and first_cmd not in (
|
||||||
@@ -2636,6 +2642,13 @@ class PipelineExecutor:
|
|||||||
):
|
):
|
||||||
print("Auto-inserting download-file after Libgen selection")
|
print("Auto-inserting download-file after Libgen selection")
|
||||||
stages.insert(0, ["download-file"])
|
stages.insert(0, ["download-file"])
|
||||||
|
if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in (
|
||||||
|
"get-tag",
|
||||||
|
"get_tag",
|
||||||
|
".pipe",
|
||||||
|
):
|
||||||
|
print("Auto-inserting get-tag after metadata selection")
|
||||||
|
stages.insert(0, ["get-tag"])
|
||||||
|
|
||||||
return True, piped_result
|
return True, piped_result
|
||||||
else:
|
else:
|
||||||
@@ -3691,6 +3704,90 @@ class PipelineExecutor:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not stages and piped_result is not None:
|
if not stages and piped_result is not None:
|
||||||
|
# Special-case: selecting metadata rows (e.g., get-tag -scrape) should
|
||||||
|
# immediately apply tags to the target item instead of just echoing a
|
||||||
|
# selection table.
|
||||||
|
try:
|
||||||
|
items = piped_result if isinstance(piped_result, list) else [piped_result]
|
||||||
|
applied_any = False
|
||||||
|
from cmdlet._shared import normalize_hash # type: ignore
|
||||||
|
from cmdlet.get_tag import _filter_scraped_tags, _emit_tags_as_table # type: ignore
|
||||||
|
from Store import Store # type: ignore
|
||||||
|
cfg_loader = ConfigLoader(root=Path.cwd())
|
||||||
|
config = cfg_loader.load()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
provider = item.get("provider")
|
||||||
|
tags = item.get("tag")
|
||||||
|
if not provider or not isinstance(tags, list) or not tags:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_hash = normalize_hash(
|
||||||
|
item.get("hash")
|
||||||
|
or item.get("hash_hex")
|
||||||
|
or item.get("file_hash")
|
||||||
|
or item.get("sha256")
|
||||||
|
)
|
||||||
|
store_name = item.get("store") or item.get("storage")
|
||||||
|
subject_path = (
|
||||||
|
item.get("path")
|
||||||
|
or item.get("target")
|
||||||
|
or item.get("filename")
|
||||||
|
)
|
||||||
|
|
||||||
|
if str(provider).strip().lower() == "ytdlp":
|
||||||
|
apply_tags = [str(t) for t in tags if t is not None]
|
||||||
|
else:
|
||||||
|
apply_tags = _filter_scraped_tags([str(t) for t in tags if t is not None])
|
||||||
|
|
||||||
|
if not apply_tags:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if store_name and file_hash:
|
||||||
|
try:
|
||||||
|
backend = Store(config)[str(store_name)]
|
||||||
|
backend.add_tag(file_hash, apply_tags, config=config)
|
||||||
|
try:
|
||||||
|
updated_tags, _src = backend.get_tag(file_hash, config=config)
|
||||||
|
except Exception:
|
||||||
|
updated_tags = apply_tags
|
||||||
|
_emit_tags_as_table(
|
||||||
|
tags_list=list(updated_tags or apply_tags),
|
||||||
|
file_hash=file_hash,
|
||||||
|
store=str(store_name),
|
||||||
|
service_name=None,
|
||||||
|
config=config,
|
||||||
|
item_title=str(item.get("title") or provider),
|
||||||
|
path=str(subject_path) if subject_path else None,
|
||||||
|
subject=item,
|
||||||
|
)
|
||||||
|
applied_any = True
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No store/hash: just emit the tags to the pipeline/view.
|
||||||
|
_emit_tags_as_table(
|
||||||
|
tags_list=list(apply_tags),
|
||||||
|
file_hash=file_hash,
|
||||||
|
store=str(store_name or "local"),
|
||||||
|
service_name=None,
|
||||||
|
config=config,
|
||||||
|
item_title=str(item.get("title") or provider),
|
||||||
|
path=str(subject_path) if subject_path else None,
|
||||||
|
subject=item,
|
||||||
|
)
|
||||||
|
applied_any = True
|
||||||
|
|
||||||
|
if applied_any:
|
||||||
|
# Selection handled; skip default selection echo.
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Fall back to default selection rendering on any failure.
|
||||||
|
pass
|
||||||
|
|
||||||
table = ResultTable("Selection Result")
|
table = ResultTable("Selection Result")
|
||||||
items = piped_result if isinstance(piped_result,
|
items = piped_result if isinstance(piped_result,
|
||||||
list) else [piped_result]
|
list) else [piped_result]
|
||||||
|
|||||||
434
Provider/HIFI.py
Normal file
434
Provider/HIFI.py
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ProviderCore.base import Provider, SearchResult
|
||||||
|
from SYS.logger import log
|
||||||
|
|
||||||
|
DEFAULT_API_URLS = (
|
||||||
|
"https://tidal-api.binimum.org",
|
||||||
|
)
|
||||||
|
|
||||||
|
_KEY_TO_PARAM: Dict[str, str] = {
|
||||||
|
"album": "al",
|
||||||
|
"artist": "a",
|
||||||
|
"playlist": "p",
|
||||||
|
"video": "v",
|
||||||
|
"song": "s",
|
||||||
|
"track": "s",
|
||||||
|
"title": "s",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DELIMITERS_RE = re.compile(r"[;,]")
|
||||||
|
_SEGMENT_BOUNDARY_RE = re.compile(r"(?=\b\w+\s*:)")
|
||||||
|
|
||||||
|
|
||||||
|
class HIFI(Provider):
|
||||||
|
"""Provider that targets the HiFi-RestAPI (Tidal proxy) search endpoint.
|
||||||
|
|
||||||
|
The CLI can supply a list of fail-over URLs via ``provider.hifi.api_urls`` or
|
||||||
|
``provider.hifi.api_url`` in the config. When not configured, it defaults to
|
||||||
|
https://tidal-api.binimum.org.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
super().__init__(config)
|
||||||
|
self.api_urls = self._resolve_api_urls()
|
||||||
|
|
||||||
|
def validate(self) -> bool:
|
||||||
|
return bool(self.api_urls)
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
limit: int = 50,
|
||||||
|
filters: Optional[Dict[str, Any]] = None,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> List[SearchResult]:
|
||||||
|
if limit <= 0:
|
||||||
|
return []
|
||||||
|
params = self._build_search_params(query)
|
||||||
|
if not params:
|
||||||
|
return []
|
||||||
|
|
||||||
|
payload: Optional[Dict[str, Any]] = None
|
||||||
|
for base in self.api_urls:
|
||||||
|
endpoint = f"{base.rstrip('/')}/search/"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(endpoint, params=params, timeout=10.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"[hifi] Search failed for {endpoint}: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
items = data.get("items") or []
|
||||||
|
results: List[SearchResult] = []
|
||||||
|
for item in items:
|
||||||
|
if limit and len(results) >= limit:
|
||||||
|
break
|
||||||
|
result = self._item_to_result(item)
|
||||||
|
if result is not None:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
def _resolve_api_urls(self) -> List[str]:
|
||||||
|
urls: List[str] = []
|
||||||
|
raw = self.config.get("api_urls")
|
||||||
|
if raw is None:
|
||||||
|
raw = self.config.get("api_url")
|
||||||
|
if isinstance(raw, (list, tuple)):
|
||||||
|
urls.extend(str(item).strip() for item in raw if isinstance(item, str))
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
urls.append(raw.strip())
|
||||||
|
cleaned = [u.rstrip("/") for u in urls if isinstance(u, str) and u.strip()]
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = [DEFAULT_API_URLS[0]]
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def _build_search_params(self, query: str) -> Dict[str, str]:
|
||||||
|
cleaned = str(query or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
segments: List[str] = []
|
||||||
|
for chunk in _DELIMITERS_RE.split(cleaned):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
if ":" in chunk:
|
||||||
|
for sub in _SEGMENT_BOUNDARY_RE.split(chunk):
|
||||||
|
part = sub.strip()
|
||||||
|
if part:
|
||||||
|
segments.append(part)
|
||||||
|
else:
|
||||||
|
segments.append(chunk)
|
||||||
|
|
||||||
|
key_values: Dict[str, str] = {}
|
||||||
|
free_text: List[str] = []
|
||||||
|
for segment in segments:
|
||||||
|
if ":" not in segment:
|
||||||
|
free_text.append(segment)
|
||||||
|
continue
|
||||||
|
key, value = segment.split(":", 1)
|
||||||
|
key = key.strip().lower()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if value:
|
||||||
|
key_values[key] = value
|
||||||
|
|
||||||
|
params: Dict[str, str] = {}
|
||||||
|
for key, value in key_values.items():
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
mapped = _KEY_TO_PARAM.get(key)
|
||||||
|
if mapped:
|
||||||
|
params[mapped] = value
|
||||||
|
|
||||||
|
general = " ".join(part for part in free_text if part).strip()
|
||||||
|
if general:
|
||||||
|
params.setdefault("s", general)
|
||||||
|
elif not params:
|
||||||
|
params["s"] = cleaned
|
||||||
|
return params
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_duration(seconds: Any) -> str:
|
||||||
|
try:
|
||||||
|
total = int(seconds)
|
||||||
|
if total < 0:
|
||||||
|
return ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
minutes, secs = divmod(total, 60)
|
||||||
|
return f"{minutes}:{secs:02d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stringify(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_artists(item: Dict[str, Any]) -> List[str]:
|
||||||
|
names: List[str] = []
|
||||||
|
artists = item.get("artists")
|
||||||
|
if isinstance(artists, list):
|
||||||
|
for artist in artists:
|
||||||
|
if isinstance(artist, dict):
|
||||||
|
name = str(artist.get("name") or "").strip()
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
if not names:
|
||||||
|
primary = item.get("artist")
|
||||||
|
if isinstance(primary, dict):
|
||||||
|
name = str(primary.get("name") or "").strip()
|
||||||
|
if name:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
title = str(item.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
identifier = item.get("id")
|
||||||
|
if identifier is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
track_id = int(identifier)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Avoid tidal.com URLs entirely; selection will resolve to a decoded MPD.
|
||||||
|
path = f"hifi://track/{track_id}"
|
||||||
|
|
||||||
|
artists = self._extract_artists(item)
|
||||||
|
artist_display = ", ".join(artists)
|
||||||
|
|
||||||
|
album = item.get("album")
|
||||||
|
album_title = ""
|
||||||
|
if isinstance(album, dict):
|
||||||
|
album_title = str(album.get("title") or "").strip()
|
||||||
|
|
||||||
|
detail_parts: List[str] = []
|
||||||
|
if artist_display:
|
||||||
|
detail_parts.append(artist_display)
|
||||||
|
if album_title:
|
||||||
|
detail_parts.append(album_title)
|
||||||
|
detail = " | ".join(detail_parts)
|
||||||
|
|
||||||
|
columns: List[tuple[str, str]] = []
|
||||||
|
if artist_display:
|
||||||
|
columns.append(("Artist", artist_display))
|
||||||
|
if album_title:
|
||||||
|
columns.append(("Album", album_title))
|
||||||
|
duration_text = self._format_duration(item.get("duration"))
|
||||||
|
if duration_text:
|
||||||
|
columns.append(("Duration", duration_text))
|
||||||
|
audio_quality = str(item.get("audioQuality") or "").strip()
|
||||||
|
if audio_quality:
|
||||||
|
columns.append(("Quality", audio_quality))
|
||||||
|
|
||||||
|
tags = {"tidal"}
|
||||||
|
if audio_quality:
|
||||||
|
tags.add(f"quality:{audio_quality.lower()}")
|
||||||
|
metadata = item.get("mediaMetadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
tag_values = metadata.get("tags") or []
|
||||||
|
for tag in tag_values:
|
||||||
|
if isinstance(tag, str) and tag.strip():
|
||||||
|
tags.add(tag.strip().lower())
|
||||||
|
|
||||||
|
return SearchResult(
|
||||||
|
table="hifi",
|
||||||
|
title=title,
|
||||||
|
path=path,
|
||||||
|
detail=detail,
|
||||||
|
annotations=["tidal"],
|
||||||
|
media_kind="audio",
|
||||||
|
tag=tags,
|
||||||
|
columns=columns,
|
||||||
|
full_metadata=item,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_track_selection_context(
|
||||||
|
self, selected_items: List[Any]
|
||||||
|
) -> List[Tuple[int, str, str]]:
|
||||||
|
contexts: List[Tuple[int, str, str]] = []
|
||||||
|
seen_ids: set[int] = set()
|
||||||
|
for item in selected_items or []:
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if isinstance(item, dict):
|
||||||
|
payload = item
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
payload = (
|
||||||
|
item.to_dict()
|
||||||
|
if hasattr(item, "to_dict")
|
||||||
|
and callable(getattr(item, "to_dict"))
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if not payload:
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"title": getattr(item, "title", None),
|
||||||
|
"path": getattr(item, "path", None),
|
||||||
|
"url": getattr(item, "url", None),
|
||||||
|
"full_metadata": getattr(item, "full_metadata", None),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
meta = (
|
||||||
|
payload.get("full_metadata")
|
||||||
|
if isinstance(payload.get("full_metadata"), dict)
|
||||||
|
else payload
|
||||||
|
)
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
raw_id = meta.get("trackId") or meta.get("id") or payload.get("id")
|
||||||
|
if raw_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
track_id = int(raw_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if track_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(track_id)
|
||||||
|
|
||||||
|
title = (
|
||||||
|
payload.get("title")
|
||||||
|
or meta.get("title")
|
||||||
|
or payload.get("name")
|
||||||
|
or payload.get("path")
|
||||||
|
or payload.get("url")
|
||||||
|
)
|
||||||
|
if not title:
|
||||||
|
title = f"Track {track_id}"
|
||||||
|
path = (
|
||||||
|
payload.get("path")
|
||||||
|
or payload.get("url")
|
||||||
|
or f"hifi://track/{track_id}"
|
||||||
|
)
|
||||||
|
contexts.append((track_id, str(title).strip(), str(path).strip()))
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
if track_id <= 0:
|
||||||
|
return None
|
||||||
|
params = {"id": str(track_id)}
|
||||||
|
for base in self.api_urls:
|
||||||
|
endpoint = f"{base.rstrip('/')}/track/"
|
||||||
|
try:
|
||||||
|
resp = httpx.get(endpoint, params=params, timeout=10.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
data = payload.get("data")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"[hifi] Track lookup failed for {endpoint}: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]:
|
||||||
|
values: List[Tuple[str, str]] = [
|
||||||
|
("Track ID", str(track_id)),
|
||||||
|
("Quality", self._stringify(detail.get("audioQuality"))),
|
||||||
|
("Mode", self._stringify(detail.get("audioMode"))),
|
||||||
|
("Asset", self._stringify(detail.get("assetPresentation"))),
|
||||||
|
("Manifest Type", self._stringify(detail.get("manifestMimeType"))),
|
||||||
|
("Manifest Hash", self._stringify(detail.get("manifestHash"))),
|
||||||
|
("Bit Depth", self._stringify(detail.get("bitDepth"))),
|
||||||
|
("Sample Rate", self._stringify(detail.get("sampleRate"))),
|
||||||
|
]
|
||||||
|
return [(name, value) for name, value in values if value]
|
||||||
|
|
||||||
|
def selector(
|
||||||
|
self,
|
||||||
|
selected_items: List[Any],
|
||||||
|
*,
|
||||||
|
ctx: Any,
|
||||||
|
stage_is_last: bool = True,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> bool:
|
||||||
|
if not stage_is_last:
|
||||||
|
return False
|
||||||
|
|
||||||
|
contexts = self._extract_track_selection_context(selected_items)
|
||||||
|
if not contexts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
track_details: List[Tuple[int, str, str, Dict[str, Any]]] = []
|
||||||
|
for track_id, title, path in contexts:
|
||||||
|
detail = self._fetch_track_details(track_id)
|
||||||
|
if detail:
|
||||||
|
track_details.append((track_id, title, path, detail))
|
||||||
|
|
||||||
|
if not track_details:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from SYS.rich_display import stdout_console
|
||||||
|
from SYS.result_table import ResultTable
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
table = ResultTable("HIFI Track").set_preserve_order(True)
|
||||||
|
table.set_table("hifi.track")
|
||||||
|
results_payload: List[Dict[str, Any]] = []
|
||||||
|
for track_id, title, path, detail in track_details:
|
||||||
|
# Decode the DASH MPD manifest to a local file and use it as the selectable/playable path.
|
||||||
|
try:
|
||||||
|
from cmdlet._shared import resolve_tidal_manifest_path
|
||||||
|
|
||||||
|
manifest_path = resolve_tidal_manifest_path(
|
||||||
|
{"full_metadata": detail, "path": f"hifi://track/{track_id}"}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
manifest_path = None
|
||||||
|
|
||||||
|
resolved_path = str(manifest_path) if manifest_path else f"hifi://track/{track_id}"
|
||||||
|
|
||||||
|
artists = self._extract_artists(detail)
|
||||||
|
artist_display = ", ".join(artists) if artists else ""
|
||||||
|
columns = self._build_track_columns(detail, track_id)
|
||||||
|
if artist_display:
|
||||||
|
columns.insert(1, ("Artist", artist_display))
|
||||||
|
album = detail.get("album")
|
||||||
|
if isinstance(album, dict):
|
||||||
|
album_title = self._stringify(album.get("title"))
|
||||||
|
else:
|
||||||
|
album_title = self._stringify(detail.get("album"))
|
||||||
|
if album_title:
|
||||||
|
insert_pos = 2 if artist_display else 1
|
||||||
|
columns.insert(insert_pos, ("Album", album_title))
|
||||||
|
|
||||||
|
result = SearchResult(
|
||||||
|
table="hifi.track",
|
||||||
|
title=title,
|
||||||
|
path=resolved_path,
|
||||||
|
detail=f"id:{track_id}",
|
||||||
|
annotations=["tidal", "track"],
|
||||||
|
media_kind="audio",
|
||||||
|
columns=columns,
|
||||||
|
full_metadata=detail,
|
||||||
|
)
|
||||||
|
table.add_result(result)
|
||||||
|
try:
|
||||||
|
results_payload.append(result.to_dict())
|
||||||
|
except Exception:
|
||||||
|
results_payload.append({
|
||||||
|
"table": "hifi.track",
|
||||||
|
"title": result.title,
|
||||||
|
"path": result.path,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx.set_last_result_table(table, results_payload)
|
||||||
|
ctx.set_current_stage_table(table)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_console().print()
|
||||||
|
stdout_console().print(table)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -8,8 +8,13 @@ import requests
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
try: # Optional dependency for IMDb scraping
|
||||||
|
from imdbinfo.services import search_title # type: ignore
|
||||||
|
except ImportError: # pragma: no cover - optional
|
||||||
|
search_title = None # type: ignore[assignment]
|
||||||
|
|
||||||
from SYS.logger import log, debug
|
from SYS.logger import log, debug
|
||||||
|
from SYS.metadata import imdb_tag
|
||||||
|
|
||||||
try: # Optional dependency
|
try: # Optional dependency
|
||||||
import musicbrainzngs # type: ignore
|
import musicbrainzngs # type: ignore
|
||||||
@@ -607,6 +612,139 @@ class MusicBrainzMetadataProvider(MetadataProvider):
|
|||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
class ImdbMetadataProvider(MetadataProvider):
|
||||||
|
"""Metadata provider for IMDb titles (movies/series/episodes)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str: # type: ignore[override]
|
||||||
|
return "imdb"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_imdb_id(text: str) -> str:
|
||||||
|
raw = str(text or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Exact tt123 pattern
|
||||||
|
m = re.search(r"(tt\d+)", raw, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
imdb_id = m.group(1).lower()
|
||||||
|
return imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||||
|
|
||||||
|
# Bare numeric IDs (e.g., "0118883")
|
||||||
|
if raw.isdigit() and len(raw) >= 6:
|
||||||
|
return f"tt{raw}"
|
||||||
|
|
||||||
|
# Last-resort: extract first digit run
|
||||||
|
m_digits = re.search(r"(\d{6,})", raw)
|
||||||
|
if m_digits:
|
||||||
|
return f"tt{m_digits.group(1)}"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
|
||||||
|
imdb_id = self._extract_imdb_id(q)
|
||||||
|
if imdb_id:
|
||||||
|
try:
|
||||||
|
data = imdb_tag(imdb_id)
|
||||||
|
raw_tags = data.get("tag") if isinstance(data, dict) else []
|
||||||
|
title = None
|
||||||
|
year = None
|
||||||
|
if isinstance(raw_tags, list):
|
||||||
|
for tag in raw_tags:
|
||||||
|
if not isinstance(tag, str):
|
||||||
|
continue
|
||||||
|
if tag.startswith("title:"):
|
||||||
|
title = tag.split(":", 1)[1]
|
||||||
|
elif tag.startswith("year:"):
|
||||||
|
year = tag.split(":", 1)[1]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"title": title or imdb_id,
|
||||||
|
"artist": "",
|
||||||
|
"album": "",
|
||||||
|
"year": str(year or ""),
|
||||||
|
"provider": self.name,
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"raw": data,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"IMDb lookup failed: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if search_title is None:
|
||||||
|
log("imdbinfo is not installed; skipping IMDb scrape", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_result = search_title(q)
|
||||||
|
titles = getattr(search_result, "titles", None) or []
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"IMDb search failed: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for entry in titles[:limit]:
|
||||||
|
imdb_id = self._extract_imdb_id(
|
||||||
|
getattr(entry, "imdb_id", None)
|
||||||
|
or getattr(entry, "imdbId", None)
|
||||||
|
or getattr(entry, "id", None)
|
||||||
|
)
|
||||||
|
title = getattr(entry, "title", "") or getattr(entry, "title_localized", "")
|
||||||
|
year = str(getattr(entry, "year", "") or "")[:4]
|
||||||
|
kind = getattr(entry, "kind", "") or ""
|
||||||
|
rating = getattr(entry, "rating", None)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"artist": "",
|
||||||
|
"album": kind,
|
||||||
|
"year": year,
|
||||||
|
"provider": self.name,
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"kind": kind,
|
||||||
|
"rating": rating,
|
||||||
|
"raw": entry,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def to_tags(self, item: Dict[str, Any]) -> List[str]:
|
||||||
|
imdb_id = self._extract_imdb_id(
|
||||||
|
item.get("imdb_id") or item.get("id") or item.get("imdb") or ""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if imdb_id:
|
||||||
|
data = imdb_tag(imdb_id)
|
||||||
|
raw_tags = data.get("tag") if isinstance(data, dict) else []
|
||||||
|
tags = [t for t in raw_tags if isinstance(t, str)]
|
||||||
|
if tags:
|
||||||
|
return tags
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"IMDb tag extraction failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
tags = super().to_tags(item)
|
||||||
|
if imdb_id:
|
||||||
|
tags.append(f"imdb:{imdb_id}")
|
||||||
|
seen: set[str] = set()
|
||||||
|
deduped: List[str] = []
|
||||||
|
for t in tags:
|
||||||
|
s = str(t or "").strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
k = s.lower()
|
||||||
|
if k in seen:
|
||||||
|
continue
|
||||||
|
seen.add(k)
|
||||||
|
deduped.append(s)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
class YtdlpMetadataProvider(MetadataProvider):
|
class YtdlpMetadataProvider(MetadataProvider):
|
||||||
"""Metadata provider that extracts tags from a supported URL using yt-dlp.
|
"""Metadata provider that extracts tags from a supported URL using yt-dlp.
|
||||||
|
|
||||||
@@ -764,6 +902,7 @@ _METADATA_PROVIDERS: Dict[str,
|
|||||||
"google": GoogleBooksMetadataProvider,
|
"google": GoogleBooksMetadataProvider,
|
||||||
"isbnsearch": ISBNsearchMetadataProvider,
|
"isbnsearch": ISBNsearchMetadataProvider,
|
||||||
"musicbrainz": MusicBrainzMetadataProvider,
|
"musicbrainz": MusicBrainzMetadataProvider,
|
||||||
|
"imdb": ImdbMetadataProvider,
|
||||||
"ytdlp": YtdlpMetadataProvider,
|
"ytdlp": YtdlpMetadataProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from Provider.zeroxzero import ZeroXZero
|
|||||||
from Provider.loc import LOC
|
from Provider.loc import LOC
|
||||||
from Provider.internetarchive import InternetArchive
|
from Provider.internetarchive import InternetArchive
|
||||||
from Provider.podcastindex import PodcastIndex
|
from Provider.podcastindex import PodcastIndex
|
||||||
|
from Provider.HIFI import HIFI
|
||||||
|
|
||||||
_PROVIDERS: Dict[str,
|
_PROVIDERS: Dict[str,
|
||||||
Type[Provider]] = {
|
Type[Provider]] = {
|
||||||
@@ -34,6 +35,7 @@ _PROVIDERS: Dict[str,
|
|||||||
"libgen": Libgen,
|
"libgen": Libgen,
|
||||||
"openlibrary": OpenLibrary,
|
"openlibrary": OpenLibrary,
|
||||||
"internetarchive": InternetArchive,
|
"internetarchive": InternetArchive,
|
||||||
|
"hifi": HIFI,
|
||||||
"soulseek": Soulseek,
|
"soulseek": Soulseek,
|
||||||
"bandcamp": Bandcamp,
|
"bandcamp": Bandcamp,
|
||||||
"youtube": YouTube,
|
"youtube": YouTube,
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from collections.abc import Iterable as IterableABC
|
from collections.abc import Iterable as IterableABC
|
||||||
|
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
@@ -53,14 +57,14 @@ class CmdletArg:
|
|||||||
"""Resolve/process the argument value using the handler if available.
|
"""Resolve/process the argument value using the handler if available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: The raw argument value to process
|
value: The raw argument value to process
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Processed value from handler, or original value if no handler
|
Processed value from handler, or original value if no handler
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
# For STORAGE arg with a handler
|
# For STORAGE arg with a handler
|
||||||
storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path.home() / "Videos"
|
storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path.home() / "Videos"
|
||||||
"""
|
"""
|
||||||
if self.handler is not None and callable(self.handler):
|
if self.handler is not None and callable(self.handler):
|
||||||
return self.handler(value)
|
return self.handler(value)
|
||||||
@@ -2435,3 +2439,224 @@ def register_url_with_local_library(
|
|||||||
return True # url already existed
|
return True # url already existed
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
|
||||||
|
"""Persist the Tidal manifest from search results and return a local path."""
|
||||||
|
|
||||||
|
metadata = None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
metadata = item.get("full_metadata") or item.get("metadata")
|
||||||
|
else:
|
||||||
|
metadata = getattr(item, "full_metadata", None) or getattr(item, "metadata", None)
|
||||||
|
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing_path = metadata.get("_tidal_manifest_path")
|
||||||
|
if existing_path:
|
||||||
|
try:
|
||||||
|
resolved = Path(str(existing_path))
|
||||||
|
if resolved.is_file():
|
||||||
|
return str(resolved)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
existing_url = metadata.get("_tidal_manifest_url")
|
||||||
|
if existing_url and isinstance(existing_url, str):
|
||||||
|
candidate = existing_url.strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raw_manifest = metadata.get("manifest")
|
||||||
|
if not raw_manifest:
|
||||||
|
# When piping directly from the HIFI search table, we may only have a track id.
|
||||||
|
# Fetch track details from the proxy so downstream stages can decode the manifest.
|
||||||
|
try:
|
||||||
|
already = bool(metadata.get("_tidal_track_details_fetched"))
|
||||||
|
except Exception:
|
||||||
|
already = False
|
||||||
|
|
||||||
|
track_id = metadata.get("trackId") or metadata.get("id")
|
||||||
|
if track_id is None:
|
||||||
|
try:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
candidate_path = item.get("path") or item.get("url")
|
||||||
|
else:
|
||||||
|
candidate_path = getattr(item, "path", None) or getattr(item, "url", None)
|
||||||
|
except Exception:
|
||||||
|
candidate_path = None
|
||||||
|
|
||||||
|
if candidate_path:
|
||||||
|
m = re.search(
|
||||||
|
r"hifi:(?://)?track[\\/](\d+)",
|
||||||
|
str(candidate_path),
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
track_id = m.group(1)
|
||||||
|
|
||||||
|
if (not already) and track_id is not None:
|
||||||
|
try:
|
||||||
|
track_int = int(track_id)
|
||||||
|
except Exception:
|
||||||
|
track_int = None
|
||||||
|
|
||||||
|
if track_int and track_int > 0:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
resp = httpx.get(
|
||||||
|
"https://tidal-api.binimum.org/track/",
|
||||||
|
params={"id": str(track_int)},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
data = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(data, dict) and data:
|
||||||
|
try:
|
||||||
|
metadata.update(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
metadata["_tidal_track_details_fetched"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raw_manifest = metadata.get("manifest")
|
||||||
|
if not raw_manifest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_str = "".join(str(raw_manifest or "").split())
|
||||||
|
if not manifest_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_bytes: bytes
|
||||||
|
try:
|
||||||
|
manifest_bytes = base64.b64decode(manifest_str, validate=True)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
manifest_bytes = base64.b64decode(manifest_str, validate=False)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
manifest_bytes = manifest_str.encode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not manifest_bytes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
head = (manifest_bytes[:1024] or b"").lstrip()
|
||||||
|
if head.startswith((b"{", b"[")):
|
||||||
|
try:
|
||||||
|
text = manifest_bytes.decode("utf-8", errors="ignore")
|
||||||
|
payload = json.loads(text)
|
||||||
|
urls = payload.get("urls") or []
|
||||||
|
selected_url = None
|
||||||
|
for candidate in urls:
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
candidate = candidate.strip()
|
||||||
|
if candidate:
|
||||||
|
selected_url = candidate
|
||||||
|
break
|
||||||
|
if selected_url:
|
||||||
|
try:
|
||||||
|
metadata["_tidal_manifest_url"] = selected_url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
log(
|
||||||
|
f"[hifi] Resolved JSON manifest for track {metadata.get('trackId') or metadata.get('id')} to {selected_url}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return selected_url
|
||||||
|
try:
|
||||||
|
metadata["_tidal_manifest_error"] = "JSON manifest contained no urls"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log(
|
||||||
|
f"[hifi] JSON manifest for track {metadata.get('trackId') or metadata.get('id')} had no playable urls",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
metadata["_tidal_manifest_error"] = (
|
||||||
|
f"Failed to parse JSON manifest: {exc}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log(
|
||||||
|
f"[hifi] Failed to parse JSON manifest for track {metadata.get('trackId') or metadata.get('id')}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
looks_like_mpd = (
|
||||||
|
head.startswith(b"<?xml")
|
||||||
|
or head.startswith(b"<MPD")
|
||||||
|
or b"<MPD" in head
|
||||||
|
)
|
||||||
|
|
||||||
|
if not looks_like_mpd:
|
||||||
|
manifest_mime = str(metadata.get("manifestMimeType") or "").strip().lower()
|
||||||
|
try:
|
||||||
|
metadata["_tidal_manifest_error"] = (
|
||||||
|
f"Decoded manifest is not an MPD XML (mime: {manifest_mime or 'unknown'})"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
log(
|
||||||
|
f"[hifi] Decoded manifest is not an MPD XML for track {metadata.get('trackId') or metadata.get('id')} (mime {manifest_mime or 'unknown'})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_hash = str(metadata.get("manifestHash") or "").strip()
|
||||||
|
track_id = metadata.get("trackId") or metadata.get("id")
|
||||||
|
identifier = manifest_hash or hashlib.sha256(manifest_bytes).hexdigest()
|
||||||
|
identifier_safe = re.sub(r"[^A-Za-z0-9_-]+", "_", identifier)[:64]
|
||||||
|
if not identifier_safe:
|
||||||
|
identifier_safe = hashlib.sha256(manifest_bytes).hexdigest()[:12]
|
||||||
|
|
||||||
|
track_safe = "tidal"
|
||||||
|
if track_id is not None:
|
||||||
|
track_safe = re.sub(r"[^A-Za-z0-9_-]+", "_", str(track_id))[:32]
|
||||||
|
if not track_safe:
|
||||||
|
track_safe = "tidal"
|
||||||
|
|
||||||
|
# Persist as .mpd for DASH manifests.
|
||||||
|
ext = "mpd"
|
||||||
|
|
||||||
|
manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "hifi"
|
||||||
|
try:
|
||||||
|
manifest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
filename = f"hifi-{track_safe}-{identifier_safe[:24]}.{ext}"
|
||||||
|
target_path = manifest_dir / filename
|
||||||
|
try:
|
||||||
|
with open(target_path, "wb") as fh:
|
||||||
|
fh.write(manifest_bytes)
|
||||||
|
metadata["_tidal_manifest_path"] = str(target_path)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
if item.get("full_metadata") is metadata:
|
||||||
|
item["full_metadata"] = metadata
|
||||||
|
elif item.get("metadata") is metadata:
|
||||||
|
item["metadata"] = metadata
|
||||||
|
else:
|
||||||
|
extra = getattr(item, "extra", None)
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
extra["_tidal_manifest_path"] = str(target_path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(target_path)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional, Sequence, Tuple, List
|
from typing import Any, Dict, Optional, Sequence, Tuple, List, Union
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import shutil
|
import shutil
|
||||||
@@ -582,6 +582,82 @@ class Add_File(Cmdlet):
|
|||||||
failures += 1
|
failures += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# If we got a hifi://track/<id> placeholder, resolve it to a decoded MPD first.
|
||||||
|
try:
|
||||||
|
if isinstance(media_path_or_url, Path):
|
||||||
|
mp_url = str(media_path_or_url)
|
||||||
|
if mp_url.lower().startswith("hifi:"):
|
||||||
|
manifest_path = sh.resolve_tidal_manifest_path(item)
|
||||||
|
if not manifest_path:
|
||||||
|
try:
|
||||||
|
meta = getattr(item, "full_metadata", None)
|
||||||
|
if isinstance(meta, dict) and meta.get("_tidal_manifest_error"):
|
||||||
|
log(str(meta.get("_tidal_manifest_error")), file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log("HIFI selection has no playable DASH MPD manifest.", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
media_path_or_url = Path(manifest_path)
|
||||||
|
pipe_obj.path = str(media_path_or_url)
|
||||||
|
elif isinstance(media_path_or_url, str):
|
||||||
|
if str(media_path_or_url).strip().lower().startswith("hifi:"):
|
||||||
|
manifest_path = sh.resolve_tidal_manifest_path(item)
|
||||||
|
if not manifest_path:
|
||||||
|
try:
|
||||||
|
meta = getattr(item, "full_metadata", None)
|
||||||
|
if isinstance(meta, dict) and meta.get("_tidal_manifest_error"):
|
||||||
|
log(str(meta.get("_tidal_manifest_error")), file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log("HIFI selection has no playable DASH MPD manifest.", file=sys.stderr)
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
media_path_or_url = Path(manifest_path)
|
||||||
|
pipe_obj.path = str(media_path_or_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
manifest_source: Optional[Union[str, Path]] = None
|
||||||
|
tidal_metadata = None
|
||||||
|
try:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
tidal_metadata = item.get("full_metadata") or item.get("metadata")
|
||||||
|
else:
|
||||||
|
tidal_metadata = (
|
||||||
|
getattr(item, "full_metadata", None)
|
||||||
|
or getattr(item, "metadata", None)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
tidal_metadata = None
|
||||||
|
|
||||||
|
if not tidal_metadata and isinstance(pipe_obj.extra, dict):
|
||||||
|
tidal_metadata = pipe_obj.extra.get("full_metadata") or pipe_obj.extra.get("metadata")
|
||||||
|
|
||||||
|
if isinstance(tidal_metadata, dict):
|
||||||
|
manifest_source = (
|
||||||
|
tidal_metadata.get("_tidal_manifest_path")
|
||||||
|
or tidal_metadata.get("_tidal_manifest_url")
|
||||||
|
)
|
||||||
|
if not manifest_source:
|
||||||
|
if isinstance(media_path_or_url, Path):
|
||||||
|
manifest_source = media_path_or_url
|
||||||
|
elif isinstance(media_path_or_url, str):
|
||||||
|
if media_path_or_url.lower().endswith(".mpd"):
|
||||||
|
manifest_source = media_path_or_url
|
||||||
|
|
||||||
|
if manifest_source:
|
||||||
|
downloaded, tmp_dir = self._download_manifest_with_ffmpeg(manifest_source)
|
||||||
|
if downloaded is None:
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
media_path_or_url = str(downloaded)
|
||||||
|
pipe_obj.path = str(downloaded)
|
||||||
|
pipe_obj.is_temp = True
|
||||||
|
delete_after_item = True
|
||||||
|
if tmp_dir is not None:
|
||||||
|
temp_dir_to_cleanup = tmp_dir
|
||||||
|
|
||||||
is_url_target = isinstance(
|
is_url_target = isinstance(
|
||||||
media_path_or_url,
|
media_path_or_url,
|
||||||
str
|
str
|
||||||
@@ -2016,10 +2092,159 @@ class Add_File(Cmdlet):
|
|||||||
# Call download-media with the URL in args
|
# Call download-media with the URL in args
|
||||||
return dl_cmdlet.run(None, dl_args, config)
|
return dl_cmdlet.run(None, dl_args, config)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _download_manifest_with_ffmpeg(source: Union[str, Path]) -> Tuple[Optional[Path], Optional[Path]]:
|
||||||
|
"""Run ffmpeg on the manifest or stream URL and return a local file path for ingestion."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
ffmpeg_bin = shutil.which("ffmpeg")
|
||||||
|
if not ffmpeg_bin:
|
||||||
|
log("ffmpeg not found on PATH; cannot download HIFI manifest.", file=sys.stderr)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
tmp_dir = Path(tempfile.mkdtemp(prefix="medeia_hifi_mpd_"))
|
||||||
|
stream_mp4 = tmp_dir / "stream.mp4"
|
||||||
|
|
||||||
|
input_target: Optional[str] = None
|
||||||
|
if isinstance(source, Path):
|
||||||
|
input_target = str(source)
|
||||||
|
elif isinstance(source, str):
|
||||||
|
candidate = source.strip()
|
||||||
|
if candidate.lower().startswith("file://"):
|
||||||
|
try:
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(candidate)
|
||||||
|
raw_path = unquote(parsed.path or "")
|
||||||
|
raw_path = raw_path.lstrip("/")
|
||||||
|
candidate = raw_path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
input_target = candidate
|
||||||
|
|
||||||
|
if not input_target:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg_bin,
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-protocol_whitelist",
|
||||||
|
"file,https,tcp,tls,crypto,data",
|
||||||
|
"-i",
|
||||||
|
input_target,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
str(stream_mp4),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
err = (exc.stderr or "").strip()
|
||||||
|
if err:
|
||||||
|
log(f"ffmpeg manifest download failed: {err}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
log(f"ffmpeg manifest download failed (exit {exc.returncode})", file=sys.stderr)
|
||||||
|
return None, tmp_dir
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"ffmpeg manifest download failed: {exc}", file=sys.stderr)
|
||||||
|
return None, tmp_dir
|
||||||
|
|
||||||
|
codec = None
|
||||||
|
ffprobe_bin = shutil.which("ffprobe")
|
||||||
|
if ffprobe_bin:
|
||||||
|
try:
|
||||||
|
probe = subprocess.run(
|
||||||
|
[
|
||||||
|
ffprobe_bin,
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=codec_name",
|
||||||
|
"-of",
|
||||||
|
"default=nw=1:nk=1",
|
||||||
|
str(stream_mp4),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
codec = (probe.stdout or "").strip().lower() or None
|
||||||
|
except Exception:
|
||||||
|
codec = None
|
||||||
|
|
||||||
|
ext = None
|
||||||
|
if codec == "flac":
|
||||||
|
ext = "flac"
|
||||||
|
elif codec == "aac":
|
||||||
|
ext = "m4a"
|
||||||
|
elif codec == "mp3":
|
||||||
|
ext = "mp3"
|
||||||
|
elif codec == "opus":
|
||||||
|
ext = "opus"
|
||||||
|
else:
|
||||||
|
ext = "mka"
|
||||||
|
|
||||||
|
audio_out = tmp_dir / f"audio.{ext}"
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
ffmpeg_bin,
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
str(stream_mp4),
|
||||||
|
"-vn",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
str(audio_out),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if audio_out.exists():
|
||||||
|
return audio_out, tmp_dir
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
err = (exc.stderr or "").strip()
|
||||||
|
if err:
|
||||||
|
log(f"ffmpeg audio extract failed: {err}", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if stream_mp4.exists():
|
||||||
|
return stream_mp4, tmp_dir
|
||||||
|
return None, tmp_dir
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_url(result: Any, pipe_obj: models.PipeObject) -> List[str]:
|
def _get_url(result: Any, pipe_obj: models.PipeObject) -> List[str]:
|
||||||
from SYS.metadata import normalize_urls
|
from SYS.metadata import normalize_urls
|
||||||
|
|
||||||
|
# If this is a HIFI selection, we only support the decoded MPD (never tidal.com URLs).
|
||||||
|
is_hifi = False
|
||||||
|
try:
|
||||||
|
if isinstance(result, dict):
|
||||||
|
is_hifi = str(result.get("table") or result.get("provider") or "").strip().lower().startswith("hifi")
|
||||||
|
else:
|
||||||
|
is_hifi = str(getattr(result, "table", "") or getattr(result, "provider", "")).strip().lower().startswith("hifi")
|
||||||
|
except Exception:
|
||||||
|
is_hifi = False
|
||||||
|
try:
|
||||||
|
if not is_hifi:
|
||||||
|
is_hifi = str(getattr(pipe_obj, "path", "") or "").strip().lower().startswith("hifi:")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Prefer explicit PipeObject.url if present
|
# Prefer explicit PipeObject.url if present
|
||||||
urls: List[str] = []
|
urls: List[str] = []
|
||||||
try:
|
try:
|
||||||
@@ -2043,6 +2268,13 @@ class Add_File(Cmdlet):
|
|||||||
if not urls:
|
if not urls:
|
||||||
urls = normalize_urls(extract_url_from_result(result))
|
urls = normalize_urls(extract_url_from_result(result))
|
||||||
|
|
||||||
|
# If this is a Tidal/HIFI selection with a decodable manifest, do NOT fall back to
|
||||||
|
# tidal.com track URLs. The only supported target is the decoded local MPD.
|
||||||
|
manifest_path = sh.resolve_tidal_manifest_path(result)
|
||||||
|
if manifest_path:
|
||||||
|
return [manifest_path]
|
||||||
|
if is_hifi:
|
||||||
|
return []
|
||||||
return urls
|
return urls
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|
||||||
from SYS import pipeline as ctx
|
from SYS import pipeline as ctx
|
||||||
|
from SYS.result_table import ResultTable
|
||||||
from . import _shared as sh
|
from . import _shared as sh
|
||||||
|
|
||||||
Cmdlet = sh.Cmdlet
|
Cmdlet = sh.Cmdlet
|
||||||
@@ -99,6 +100,8 @@ class Get_Note(Cmdlet):
|
|||||||
|
|
||||||
store_registry = Store(config)
|
store_registry = Store(config)
|
||||||
any_notes = False
|
any_notes = False
|
||||||
|
display_items: List[Dict[str, Any]] = []
|
||||||
|
note_table: Optional[ResultTable] = None
|
||||||
|
|
||||||
for res in results:
|
for res in results:
|
||||||
if not isinstance(res, dict):
|
if not isinstance(res, dict):
|
||||||
@@ -145,6 +148,13 @@ class Get_Note(Cmdlet):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
any_notes = True
|
any_notes = True
|
||||||
|
if note_table is None:
|
||||||
|
note_table = (
|
||||||
|
ResultTable("note")
|
||||||
|
.set_table("note")
|
||||||
|
.set_value_case("preserve")
|
||||||
|
.set_preserve_order(True)
|
||||||
|
)
|
||||||
# Emit each note as its own row so CLI renders a proper note table
|
# Emit each note as its own row so CLI renders a proper note table
|
||||||
for k in sorted(notes.keys(), key=lambda x: str(x).lower()):
|
for k in sorted(notes.keys(), key=lambda x: str(x).lower()):
|
||||||
v = notes.get(k)
|
v = notes.get(k)
|
||||||
@@ -152,23 +162,27 @@ class Get_Note(Cmdlet):
|
|||||||
# Keep payload small for IPC/pipes.
|
# Keep payload small for IPC/pipes.
|
||||||
raw_text = raw_text[:999]
|
raw_text = raw_text[:999]
|
||||||
preview = " ".join(raw_text.replace("\r", "").split("\n"))
|
preview = " ".join(raw_text.replace("\r", "").split("\n"))
|
||||||
ctx.emit(
|
payload: Dict[str, Any] = {
|
||||||
{
|
"store": store_name,
|
||||||
"store": store_name,
|
"hash": resolved_hash,
|
||||||
"hash": resolved_hash,
|
"note_name": str(k),
|
||||||
"note_name": str(k),
|
"note_text": raw_text,
|
||||||
"note_text": raw_text,
|
"columns": [
|
||||||
"columns": [
|
("Name",
|
||||||
("Name",
|
str(k)),
|
||||||
str(k)),
|
("Text",
|
||||||
("Text",
|
preview.strip()),
|
||||||
preview.strip()),
|
],
|
||||||
],
|
}
|
||||||
}
|
display_items.append(payload)
|
||||||
)
|
if note_table is not None:
|
||||||
|
note_table.add_result(payload)
|
||||||
|
ctx.emit(payload)
|
||||||
|
|
||||||
if not any_notes:
|
if not any_notes:
|
||||||
ctx.emit("No notes found.")
|
ctx.emit("No notes found.")
|
||||||
|
elif note_table is not None:
|
||||||
|
ctx.set_last_result_table(note_table, display_items, subject=result)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1118,7 +1118,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
-query "hash:<sha256>": Override hash to use instead of result's hash
|
-query "hash:<sha256>": Override hash to use instead of result's hash
|
||||||
--store <key>: Store result to this key for pipeline
|
--store <key>: Store result to this key for pipeline
|
||||||
--emit: Emit result without interactive prompt (quiet mode)
|
--emit: Emit result without interactive prompt (quiet mode)
|
||||||
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks)
|
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks, imdb)
|
||||||
"""
|
"""
|
||||||
args_list = [str(arg) for arg in (args or [])]
|
args_list = [str(arg) for arg in (args or [])]
|
||||||
raw_args = list(args_list)
|
raw_args = list(args_list)
|
||||||
@@ -1367,7 +1367,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
print(json_module.dumps(output, ensure_ascii=False))
|
print(json_module.dumps(output, ensure_ascii=False))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Provider scraping (e.g., itunes)
|
# Provider scraping (e.g., itunes, imdb)
|
||||||
provider = get_metadata_provider(scrape_url, config)
|
provider = get_metadata_provider(scrape_url, config)
|
||||||
if provider is None:
|
if provider is None:
|
||||||
log(f"Unknown metadata provider: {scrape_url}", file=sys.stderr)
|
log(f"Unknown metadata provider: {scrape_url}", file=sys.stderr)
|
||||||
@@ -1447,6 +1447,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
identifiers.get("isbn_13") or identifiers.get("isbn_10")
|
identifiers.get("isbn_13") or identifiers.get("isbn_10")
|
||||||
or identifiers.get("isbn") or identifiers.get("openlibrary")
|
or identifiers.get("isbn") or identifiers.get("openlibrary")
|
||||||
)
|
)
|
||||||
|
elif provider.name == "imdb":
|
||||||
|
identifier_query = identifiers.get("imdb")
|
||||||
elif provider.name == "itunes":
|
elif provider.name == "itunes":
|
||||||
identifier_query = identifiers.get("musicbrainz") or identifiers.get(
|
identifier_query = identifiers.get("musicbrainz") or identifiers.get(
|
||||||
"musicbrainzalbum"
|
"musicbrainzalbum"
|
||||||
@@ -1557,6 +1559,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
from SYS.result_table import ResultTable
|
from SYS.result_table import ResultTable
|
||||||
|
|
||||||
table = ResultTable(f"Metadata: {provider.name}")
|
table = ResultTable(f"Metadata: {provider.name}")
|
||||||
|
table.set_table(f"metadata.{provider.name}")
|
||||||
table.set_source_command("get-tag", [])
|
table.set_source_command("get-tag", [])
|
||||||
selection_payload = []
|
selection_payload = []
|
||||||
hash_for_payload = normalize_hash(hash_override) or normalize_hash(
|
hash_for_payload = normalize_hash(hash_override) or normalize_hash(
|
||||||
@@ -1601,10 +1604,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
selection_payload.append(payload)
|
selection_payload.append(payload)
|
||||||
table.set_row_selection_args(idx, [str(idx + 1)])
|
table.set_row_selection_args(idx, [str(idx + 1)])
|
||||||
|
|
||||||
|
# Store an overlay so that a subsequent `@N` selects from THIS metadata table,
|
||||||
|
# not from the previous searchable table.
|
||||||
ctx.set_last_result_table_overlay(table, selection_payload)
|
ctx.set_last_result_table_overlay(table, selection_payload)
|
||||||
ctx.set_current_stage_table(table)
|
ctx.set_current_stage_table(table)
|
||||||
# Preserve items for @ selection and downstream pipes without emitting duplicates
|
|
||||||
ctx.set_last_result_items_only(selection_payload)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# If -scrape was requested but no URL, that's an error
|
# If -scrape was requested but no URL, that's an error
|
||||||
@@ -1653,6 +1656,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
path=str(subject_path) if subject_path else None,
|
path=str(subject_path) if subject_path else None,
|
||||||
subject=result,
|
subject=result,
|
||||||
)
|
)
|
||||||
|
_emit_tag_payload(
|
||||||
|
str(result_provider),
|
||||||
|
[str(t) for t in result_tags if t is not None],
|
||||||
|
hash_value=file_hash,
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Apply tags to the store backend (no sidecar writing here).
|
# Apply tags to the store backend (no sidecar writing here).
|
||||||
@@ -1716,6 +1724,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_tag_payload(
|
||||||
|
str(store_name),
|
||||||
|
list(updated_tags),
|
||||||
|
hash_value=file_hash,
|
||||||
|
extra={"applied_provider": str(result_provider)},
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
hash_from_result = normalize_hash(get_field(result, "hash", None))
|
hash_from_result = normalize_hash(get_field(result, "hash", None))
|
||||||
@@ -1825,7 +1839,14 @@ _SCRAPE_CHOICES = []
|
|||||||
try:
|
try:
|
||||||
_SCRAPE_CHOICES = sorted(list_metadata_providers().keys())
|
_SCRAPE_CHOICES = sorted(list_metadata_providers().keys())
|
||||||
except Exception:
|
except Exception:
|
||||||
_SCRAPE_CHOICES = ["itunes", "openlibrary", "googlebooks", "google", "musicbrainz"]
|
_SCRAPE_CHOICES = [
|
||||||
|
"itunes",
|
||||||
|
"openlibrary",
|
||||||
|
"googlebooks",
|
||||||
|
"google",
|
||||||
|
"musicbrainz",
|
||||||
|
"imdb",
|
||||||
|
]
|
||||||
|
|
||||||
# Special scrape mode: pull tags from an item's URL via yt-dlp (no download)
|
# Special scrape mode: pull tags from an item's URL via yt-dlp (no download)
|
||||||
if "ytdlp" not in _SCRAPE_CHOICES:
|
if "ytdlp" not in _SCRAPE_CHOICES:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class search_file(Cmdlet):
|
|||||||
"provider",
|
"provider",
|
||||||
type="string",
|
type="string",
|
||||||
description=
|
description=
|
||||||
"External provider name: bandcamp, libgen, soulseek, youtube, alldebrid, loc, internetarchive",
|
"External provider name: bandcamp, libgen, soulseek, youtube, alldebrid, loc, internetarchive, hifi",
|
||||||
),
|
),
|
||||||
CmdletArg(
|
CmdletArg(
|
||||||
"open",
|
"open",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args
|
from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args, resolve_tidal_manifest_path
|
||||||
from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream
|
from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream
|
||||||
from SYS.result_table import ResultTable
|
from SYS.result_table import ResultTable
|
||||||
from MPV.mpv_ipc import MPV
|
from MPV.mpv_ipc import MPV
|
||||||
@@ -723,6 +723,28 @@ def _get_playable_path(
|
|||||||
"none"}:
|
"none"}:
|
||||||
path = None
|
path = None
|
||||||
|
|
||||||
|
manifest_path = resolve_tidal_manifest_path(item)
|
||||||
|
if manifest_path:
|
||||||
|
path = manifest_path
|
||||||
|
else:
|
||||||
|
# If this is a hifi:// placeholder and we couldn't resolve a manifest, do not fall back.
|
||||||
|
try:
|
||||||
|
if isinstance(path, str) and path.strip().lower().startswith("hifi:"):
|
||||||
|
try:
|
||||||
|
meta = None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
meta = item.get("full_metadata") or item.get("metadata")
|
||||||
|
else:
|
||||||
|
meta = getattr(item, "full_metadata", None) or getattr(item, "metadata", None)
|
||||||
|
if isinstance(meta, dict) and meta.get("_tidal_manifest_error"):
|
||||||
|
print(str(meta.get("_tidal_manifest_error")), file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("HIFI selection has no playable DASH MPD manifest.", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if title is not None and not isinstance(title, str):
|
if title is not None and not isinstance(title, str):
|
||||||
title = str(title)
|
title = str(title)
|
||||||
|
|
||||||
@@ -885,6 +907,25 @@ def _queue_items(
|
|||||||
|
|
||||||
target, title = result
|
target, title = result
|
||||||
|
|
||||||
|
# MPD/DASH playback requires ffmpeg protocol whitelist (file + https + crypto etc).
|
||||||
|
# Set it via IPC before loadfile so the currently running MPV can play the manifest.
|
||||||
|
try:
|
||||||
|
target_str = str(target or "")
|
||||||
|
if re.search(r"\.mpd($|\?)", target_str.lower()):
|
||||||
|
_send_ipc_command(
|
||||||
|
{
|
||||||
|
"command": [
|
||||||
|
"set_property",
|
||||||
|
"options/demuxer-lavf-o",
|
||||||
|
"protocol_whitelist=file,https,tcp,tls,crypto,data",
|
||||||
|
],
|
||||||
|
"request_id": 198,
|
||||||
|
},
|
||||||
|
silent=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV.
|
# If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV.
|
||||||
try:
|
try:
|
||||||
if isinstance(target, str):
|
if isinstance(target, str):
|
||||||
@@ -1894,6 +1935,27 @@ def _start_mpv(
|
|||||||
"--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]",
|
"--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# If we are going to play a DASH MPD, allow ffmpeg to fetch https segments referenced by the manifest.
|
||||||
|
try:
|
||||||
|
needs_mpd_whitelist = False
|
||||||
|
for it in items or []:
|
||||||
|
mpd = resolve_tidal_manifest_path(it)
|
||||||
|
candidate = mpd
|
||||||
|
if not candidate:
|
||||||
|
if isinstance(it, dict):
|
||||||
|
candidate = it.get("path") or it.get("url")
|
||||||
|
else:
|
||||||
|
candidate = getattr(it, "path", None) or getattr(it, "url", None)
|
||||||
|
if candidate and re.search(r"\.mpd($|\?)", str(candidate).lower()):
|
||||||
|
needs_mpd_whitelist = True
|
||||||
|
break
|
||||||
|
if needs_mpd_whitelist:
|
||||||
|
extra_args.append(
|
||||||
|
"--demuxer-lavf-o=protocol_whitelist=file,https,tcp,tls,crypto,data"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Optional: borderless window (useful for uosc-like overlay UI without fullscreen).
|
# Optional: borderless window (useful for uosc-like overlay UI without fullscreen).
|
||||||
if start_opts and start_opts.get("borderless"):
|
if start_opts and start_opts.get("borderless"):
|
||||||
extra_args.append("--border=no")
|
extra_args.append("--border=no")
|
||||||
|
|||||||
Reference in New Issue
Block a user