This commit is contained in:
2025-12-31 05:17:37 -08:00
parent 3bbaa28fb4
commit e8842ceded
10 changed files with 1255 additions and 29 deletions

101
CLI.py
View File

@@ -2556,8 +2556,11 @@ class PipelineExecutor:
# Auto-insert downloader stages for provider tables.
try:
current_table = ctx.get_current_stage_table(
) or ctx.get_last_result_table()
current_table = ctx.get_current_stage_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:
current_table = None
table_type = (
@@ -2584,6 +2587,9 @@ class PipelineExecutor:
"libgen"}:
print("Auto-piping selection to 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:
first_cmd = stages[0][0] if stages and stages[0] else None
if table_type == "soulseek" and first_cmd not in (
@@ -2636,6 +2642,13 @@ class PipelineExecutor:
):
print("Auto-inserting download-file after Libgen selection")
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
else:
@@ -3691,6 +3704,90 @@ class PipelineExecutor:
pass
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")
items = piped_result if isinstance(piped_result,
list) else [piped_result]

434
Provider/HIFI.py Normal file
View 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

View File

@@ -8,8 +8,13 @@ import requests
import sys
import json
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.metadata import imdb_tag
try: # Optional dependency
import musicbrainzngs # type: ignore
@@ -607,6 +612,139 @@ class MusicBrainzMetadataProvider(MetadataProvider):
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):
"""Metadata provider that extracts tags from a supported URL using yt-dlp.
@@ -764,6 +902,7 @@ _METADATA_PROVIDERS: Dict[str,
"google": GoogleBooksMetadataProvider,
"isbnsearch": ISBNsearchMetadataProvider,
"musicbrainz": MusicBrainzMetadataProvider,
"imdb": ImdbMetadataProvider,
"ytdlp": YtdlpMetadataProvider,
}

View File

@@ -26,6 +26,7 @@ from Provider.zeroxzero import ZeroXZero
from Provider.loc import LOC
from Provider.internetarchive import InternetArchive
from Provider.podcastindex import PodcastIndex
from Provider.HIFI import HIFI
_PROVIDERS: Dict[str,
Type[Provider]] = {
@@ -34,6 +35,7 @@ _PROVIDERS: Dict[str,
"libgen": Libgen,
"openlibrary": OpenLibrary,
"internetarchive": InternetArchive,
"hifi": HIFI,
"soulseek": Soulseek,
"bandcamp": Bandcamp,
"youtube": YouTube,

View File

@@ -2,9 +2,13 @@
from __future__ import annotations
import base64
import hashlib
import json
import re
import shutil
import sys
import tempfile
from collections.abc import Iterable as IterableABC
from SYS.logger import log
@@ -53,14 +57,14 @@ class CmdletArg:
"""Resolve/process the argument value using the handler if available.
Args:
value: The raw argument value to process
value: The raw argument value to process
Returns:
Processed value from handler, or original value if no handler
Processed value from handler, or original value if no handler
Example:
# For STORAGE arg with a handler
storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path.home() / "Videos"
# For STORAGE arg with a handler
storage_path = SharedArgs.STORAGE.resolve('local') # Returns Path.home() / "Videos"
"""
if self.handler is not None and callable(self.handler):
return self.handler(value)
@@ -2435,3 +2439,224 @@ def register_url_with_local_library(
return True # url already existed
except Exception:
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)

View File

@@ -1,6 +1,6 @@
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
import sys
import shutil
@@ -582,6 +582,82 @@ class Add_File(Cmdlet):
failures += 1
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(
media_path_or_url,
str
@@ -2016,10 +2092,159 @@ class Add_File(Cmdlet):
# Call download-media with the URL in args
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
def _get_url(result: Any, pipe_obj: models.PipeObject) -> List[str]:
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
urls: List[str] = []
try:
@@ -2043,6 +2268,13 @@ class Add_File(Cmdlet):
if not urls:
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
@staticmethod

View File

@@ -1,12 +1,13 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
from typing import Any, Dict, List, Optional, Sequence
import sys
from SYS.logger import log
from SYS import pipeline as ctx
from SYS.result_table import ResultTable
from . import _shared as sh
Cmdlet = sh.Cmdlet
@@ -99,6 +100,8 @@ class Get_Note(Cmdlet):
store_registry = Store(config)
any_notes = False
display_items: List[Dict[str, Any]] = []
note_table: Optional[ResultTable] = None
for res in results:
if not isinstance(res, dict):
@@ -145,6 +148,13 @@ class Get_Note(Cmdlet):
continue
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
for k in sorted(notes.keys(), key=lambda x: str(x).lower()):
v = notes.get(k)
@@ -152,23 +162,27 @@ class Get_Note(Cmdlet):
# Keep payload small for IPC/pipes.
raw_text = raw_text[:999]
preview = " ".join(raw_text.replace("\r", "").split("\n"))
ctx.emit(
{
"store": store_name,
"hash": resolved_hash,
"note_name": str(k),
"note_text": raw_text,
"columns": [
("Name",
str(k)),
("Text",
preview.strip()),
],
}
)
payload: Dict[str, Any] = {
"store": store_name,
"hash": resolved_hash,
"note_name": str(k),
"note_text": raw_text,
"columns": [
("Name",
str(k)),
("Text",
preview.strip()),
],
}
display_items.append(payload)
if note_table is not None:
note_table.add_result(payload)
ctx.emit(payload)
if not any_notes:
ctx.emit("No notes found.")
elif note_table is not None:
ctx.set_last_result_table(note_table, display_items, subject=result)
return 0

View File

@@ -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
--store <key>: Store result to this key for pipeline
--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 [])]
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))
return 0
# Provider scraping (e.g., itunes)
# Provider scraping (e.g., itunes, imdb)
provider = get_metadata_provider(scrape_url, config)
if provider is None:
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")
or identifiers.get("isbn") or identifiers.get("openlibrary")
)
elif provider.name == "imdb":
identifier_query = identifiers.get("imdb")
elif provider.name == "itunes":
identifier_query = identifiers.get("musicbrainz") or identifiers.get(
"musicbrainzalbum"
@@ -1557,6 +1559,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
from SYS.result_table import ResultTable
table = ResultTable(f"Metadata: {provider.name}")
table.set_table(f"metadata.{provider.name}")
table.set_source_command("get-tag", [])
selection_payload = []
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)
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_current_stage_table(table)
# Preserve items for @ selection and downstream pipes without emitting duplicates
ctx.set_last_result_items_only(selection_payload)
return 0
# 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,
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
# 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
hash_from_result = normalize_hash(get_field(result, "hash", None))
@@ -1825,7 +1839,14 @@ _SCRAPE_CHOICES = []
try:
_SCRAPE_CHOICES = sorted(list_metadata_providers().keys())
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)
if "ytdlp" not in _SCRAPE_CHOICES:

View File

@@ -62,7 +62,7 @@ class search_file(Cmdlet):
"provider",
type="string",
description=
"External provider name: bandcamp, libgen, soulseek, youtube, alldebrid, loc, internetarchive",
"External provider name: bandcamp, libgen, soulseek, youtube, alldebrid, loc, internetarchive, hifi",
),
CmdletArg(
"open",

View File

@@ -7,7 +7,7 @@ import re
import subprocess
from urllib.parse import urlparse, parse_qs
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.result_table import ResultTable
from MPV.mpv_ipc import MPV
@@ -723,6 +723,28 @@ def _get_playable_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):
title = str(title)
@@ -885,6 +907,25 @@ def _queue_items(
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.
try:
if isinstance(target, str):
@@ -1894,6 +1935,27 @@ def _start_mpv(
"--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).
if start_opts and start_opts.get("borderless"):
extra_args.append("--border=no")