dfd
This commit is contained in:
55
CLI.py
55
CLI.py
@@ -1112,6 +1112,28 @@ def _execute_pipeline(tokens: list):
|
||||
|
||||
cmd_name = stage_tokens[0].replace("_", "-").lower()
|
||||
stage_args = stage_tokens[1:]
|
||||
|
||||
# Bare '@' means "use the subject for the current result table" (e.g., the file whose tags/URLs are shown)
|
||||
if cmd_name == "@":
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
print("No current result context available for '@'\n")
|
||||
pipeline_status = "failed"
|
||||
pipeline_error = "No result subject for @"
|
||||
return
|
||||
# Normalize to list for downstream expectations
|
||||
piped_result = subject
|
||||
try:
|
||||
subject_items = subject if isinstance(subject, list) else [subject]
|
||||
ctx.set_last_items(subject_items)
|
||||
except Exception:
|
||||
pass
|
||||
if pipeline_session and worker_manager:
|
||||
try:
|
||||
worker_manager.log_step(pipeline_session.worker_id, "@ used current table subject")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Check if this is a selection syntax (@N, @N-M, @{N,M,K}, @*, @3,5,7, @3-6,8) instead of a command
|
||||
if cmd_name.startswith('@'):
|
||||
@@ -1280,8 +1302,11 @@ def _execute_pipeline(tokens: list):
|
||||
}
|
||||
# Commands that manage their own table/history state (e.g. get-tag)
|
||||
self_managing_commands = {
|
||||
'get-tag', 'get_tag', 'tags'
|
||||
'get-tag', 'get_tag', 'tags',
|
||||
'search-file', 'search_file'
|
||||
}
|
||||
|
||||
overlay_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None
|
||||
|
||||
if cmd_name in self_managing_commands:
|
||||
# Command has already set the table and history
|
||||
@@ -1302,22 +1327,28 @@ def _execute_pipeline(tokens: list):
|
||||
for emitted in pipeline_ctx.emits:
|
||||
table.add_result(emitted)
|
||||
else:
|
||||
table = ResultTable(table_title)
|
||||
for emitted in pipeline_ctx.emits:
|
||||
table.add_result(emitted)
|
||||
|
||||
if cmd_name in selectable_commands:
|
||||
table = ResultTable(table_title)
|
||||
for emitted in pipeline_ctx.emits:
|
||||
table.add_result(emitted)
|
||||
table.set_source_command(cmd_name, stage_args)
|
||||
ctx.set_last_result_table(table, pipeline_ctx.emits)
|
||||
elif cmd_name in display_only_commands:
|
||||
table = ResultTable(table_title)
|
||||
for emitted in pipeline_ctx.emits:
|
||||
table.add_result(emitted)
|
||||
# Display-only: show table but preserve search context
|
||||
ctx.set_last_result_items_only(pipeline_ctx.emits)
|
||||
else:
|
||||
# Action commands (add-*, delete-*): update items only, don't change table/history
|
||||
ctx.set_last_result_items_only(pipeline_ctx.emits)
|
||||
# Action commands: avoid overwriting search history/table unless a display overlay exists
|
||||
if overlay_table is not None:
|
||||
table = overlay_table
|
||||
else:
|
||||
table = None
|
||||
|
||||
print()
|
||||
print(table.format_plain())
|
||||
if table is not None:
|
||||
print()
|
||||
print(table.format_plain())
|
||||
else:
|
||||
for emitted in pipeline_ctx.emits:
|
||||
if isinstance(emitted, dict):
|
||||
@@ -1518,7 +1549,8 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
}
|
||||
# Commands that manage their own table/history state (e.g. get-tag)
|
||||
self_managing_commands = {
|
||||
'get-tag', 'get_tag', 'tags'
|
||||
'get-tag', 'get_tag', 'tags',
|
||||
'search-file', 'search_file'
|
||||
}
|
||||
|
||||
if cmd_name in self_managing_commands:
|
||||
@@ -1574,7 +1606,8 @@ def _execute_cmdlet(cmd_name: str, args: list):
|
||||
'check-file-status', 'check_file_status'
|
||||
}
|
||||
self_managing_commands = {
|
||||
'get-tag', 'get_tag', 'tags'
|
||||
'get-tag', 'get_tag', 'tags',
|
||||
'search-file', 'search_file'
|
||||
}
|
||||
|
||||
if cmd_name in self_managing_commands:
|
||||
|
||||
@@ -248,6 +248,20 @@ class PipelineExecutor:
|
||||
piped_input: Any,
|
||||
on_log: Optional[Callable[[str], None]],
|
||||
) -> PipelineStageResult:
|
||||
# Bare '@' means use the subject associated with the current result table (e.g., the file shown in a tag/URL view)
|
||||
if token == "@":
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
stage.status = "failed"
|
||||
stage.error = "Selection requested (@) but there is no current result context"
|
||||
return stage
|
||||
stage.emitted = subject if isinstance(subject, list) else [subject]
|
||||
ctx.set_last_items(stage.emitted)
|
||||
stage.status = "completed"
|
||||
if on_log:
|
||||
on_log("Selected current table subject via @")
|
||||
return stage
|
||||
|
||||
selection = self._parse_selection(token)
|
||||
items = piped_input or []
|
||||
if not isinstance(items, list):
|
||||
|
||||
@@ -177,7 +177,7 @@ class SharedArgs:
|
||||
LIBRARY = CmdletArg(
|
||||
"library",
|
||||
type="string",
|
||||
choices=["hydrus", "local", "soulseek", "libgen", "debrid", "ftp"],
|
||||
choices=["hydrus", "local", "soulseek", "libgen", "ftp"],
|
||||
description="Search library or source location."
|
||||
)
|
||||
|
||||
@@ -209,7 +209,7 @@ class SharedArgs:
|
||||
STORAGE = CmdletArg(
|
||||
"storage",
|
||||
type="enum",
|
||||
choices=["hydrus", "local", "debrid", "ftp", "matrix"],
|
||||
choices=["hydrus", "local", "ftp", "matrix"],
|
||||
required=False,
|
||||
description="Storage location or destination for saving/uploading files.",
|
||||
alias="s",
|
||||
@@ -240,12 +240,12 @@ class SharedArgs:
|
||||
def resolve_storage(storage_value: Optional[str], default: Optional[Path] = None) -> Path:
|
||||
"""Resolve a storage location name to a filesystem Path.
|
||||
|
||||
Maps storage identifiers (hydrus, local, debrid, ftp) to their actual
|
||||
Maps storage identifiers (hydrus, local, ftp) to their actual
|
||||
filesystem paths. This is the single source of truth for storage location resolution.
|
||||
Note: 0x0.st is now accessed via file providers (-provider 0x0), not storage.
|
||||
|
||||
Args:
|
||||
storage_value: One of 'hydrus', 'local', 'debrid', 'ftp', or None
|
||||
storage_value: One of 'hydrus', 'local', 'ftp', or None
|
||||
default: Path to return if storage_value is None (defaults to Videos)
|
||||
|
||||
Returns:
|
||||
@@ -266,7 +266,6 @@ class SharedArgs:
|
||||
storage_map = {
|
||||
'local': Path.home() / "Videos",
|
||||
'hydrus': Path.home() / ".hydrus" / "client_files",
|
||||
'debrid': Path.home() / "Debrid",
|
||||
'ftp': Path.home() / "FTP",
|
||||
'matrix': Path.home() / "Matrix", # Placeholder, not used for upload path
|
||||
}
|
||||
|
||||
@@ -185,7 +185,13 @@ def _persist_local_metadata(
|
||||
log(traceback.format_exc(), file=sys.stderr)
|
||||
|
||||
|
||||
def _handle_local_transfer(media_path: Path, destination_root: Path, result: Any, config: Optional[Dict[str, Any]] = None) -> Tuple[int, Optional[Path]]:
|
||||
def _handle_local_transfer(
|
||||
media_path: Path,
|
||||
destination_root: Path,
|
||||
result: Any,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
export_mode: bool = False,
|
||||
) -> Tuple[int, Optional[Path]]:
|
||||
"""Transfer a file to local storage and return (exit_code, destination_path).
|
||||
|
||||
Args:
|
||||
@@ -246,34 +252,60 @@ def _handle_local_transfer(media_path: Path, destination_root: Path, result: Any
|
||||
relationships = extract_relationships(result)
|
||||
duration = extract_duration(result)
|
||||
|
||||
# Rename source file if title tag is present (to ensure destination has correct name)
|
||||
title_tag = next((t for t in merged_tags if str(t).strip().lower().startswith("title:")), None)
|
||||
if title_tag:
|
||||
try:
|
||||
from helper.utils import unique_path
|
||||
title_val = title_tag.split(":", 1)[1].strip()
|
||||
# Sanitize filename (keep spaces, but remove illegal chars)
|
||||
safe_title = "".join(c for c in title_val if c.isalnum() or c in " ._-()[]").strip()
|
||||
if safe_title:
|
||||
new_name = safe_title + media_path.suffix
|
||||
new_path = media_path.parent / new_name
|
||||
if new_path != media_path:
|
||||
# Ensure we don't overwrite existing files
|
||||
new_path = unique_path(new_path)
|
||||
media_path.rename(new_path)
|
||||
media_path = new_path
|
||||
debug(f"Renamed source file to match title: {media_path.name}")
|
||||
except Exception as e:
|
||||
log(f"Warning: Failed to rename file to match title: {e}", file=sys.stderr)
|
||||
# Skip title-based renaming for library mode (hash-based) but allow for export mode below
|
||||
|
||||
try:
|
||||
# Ensure filename is the hash when adding to local storage
|
||||
resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path)
|
||||
if resolved_hash:
|
||||
hashed_name = resolved_hash + media_path.suffix
|
||||
target_path = destination_root / hashed_name
|
||||
media_path = media_path.rename(target_path) if media_path != target_path else media_path
|
||||
dest_file = storage["local"].upload(media_path, location=str(destination_root), move=True)
|
||||
if export_mode:
|
||||
title_tag = next((t for t in merged_tags if str(t).strip().lower().startswith("title:")), None)
|
||||
title_value = ""
|
||||
if title_tag:
|
||||
title_value = title_tag.split(":", 1)[1].strip()
|
||||
if not title_value:
|
||||
title_value = media_path.stem.replace("_", " ").strip()
|
||||
# Sanitize filename
|
||||
safe_title = "".join(c for c in title_value if c.isalnum() or c in " ._-()[]{}'`").strip()
|
||||
base_name = safe_title or media_path.stem
|
||||
new_name = base_name + media_path.suffix
|
||||
target_path = destination_root / new_name
|
||||
destination_root.mkdir(parents=True, exist_ok=True)
|
||||
if target_path.exists():
|
||||
from helper.utils import unique_path
|
||||
target_path = unique_path(target_path)
|
||||
shutil.move(str(media_path), target_path)
|
||||
|
||||
# Move/copy sidecar files alongside
|
||||
possible_sidecars = [
|
||||
media_path.with_suffix(media_path.suffix + ".json"),
|
||||
media_path.with_name(media_path.name + ".tags"),
|
||||
media_path.with_name(media_path.name + ".tags.txt"),
|
||||
media_path.with_name(media_path.name + ".metadata"),
|
||||
media_path.with_name(media_path.name + ".notes"),
|
||||
]
|
||||
for sc in possible_sidecars:
|
||||
try:
|
||||
if sc.exists():
|
||||
suffix_part = sc.name.replace(media_path.name, "", 1)
|
||||
dest_sidecar = target_path.parent / f"{target_path.name}{suffix_part}"
|
||||
dest_sidecar.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(sc), dest_sidecar)
|
||||
except Exception:
|
||||
pass
|
||||
media_path = target_path
|
||||
dest_file = str(target_path)
|
||||
else:
|
||||
# Ensure filename is the hash when adding to local storage
|
||||
resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path)
|
||||
if resolved_hash:
|
||||
hashed_name = resolved_hash + media_path.suffix
|
||||
target_path = destination_root / hashed_name
|
||||
try:
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
if media_path != target_path:
|
||||
media_path = media_path.rename(target_path)
|
||||
dest_file = storage["local"].upload(media_path, location=str(destination_root), move=True)
|
||||
except Exception as exc:
|
||||
log(f"❌ Failed to move file into {destination_root}: {exc}", file=sys.stderr)
|
||||
return 1, None
|
||||
@@ -291,9 +323,12 @@ def _handle_local_transfer(media_path: Path, destination_root: Path, result: Any
|
||||
if filename_title:
|
||||
final_tags.insert(0, f"title:{filename_title}")
|
||||
|
||||
_persist_local_metadata(destination_root, dest_path, final_tags, merged_urls, file_hash, relationships, duration, media_kind)
|
||||
_cleanup_sidecar_files(media_path, sidecar_path)
|
||||
debug(f"✅ Moved to local library: {dest_path}")
|
||||
if not export_mode:
|
||||
_persist_local_metadata(destination_root, dest_path, final_tags, merged_urls, file_hash, relationships, duration, media_kind)
|
||||
_cleanup_sidecar_files(media_path, sidecar_path)
|
||||
debug(f"✅ Moved to local library: {dest_path}")
|
||||
else:
|
||||
debug(f"✅ Exported to destination: {dest_path}")
|
||||
return 0, dest_path
|
||||
|
||||
|
||||
@@ -333,17 +368,26 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
provider_name: Optional[str] = None
|
||||
delete_after_upload = False
|
||||
|
||||
# Check if -path argument was provided to use direct file path instead of piped result
|
||||
# Check if -path argument was provided
|
||||
path_arg = parsed.get("path")
|
||||
if path_arg:
|
||||
# Create a pseudo-result object from the file path
|
||||
media_path = Path(str(path_arg).strip())
|
||||
if not media_path.exists():
|
||||
log(f"❌ File not found: {media_path}")
|
||||
return 1
|
||||
# Create result dict with the file path and origin 'wild' for direct path inputs
|
||||
result = {"target": str(media_path), "origin": "wild"}
|
||||
log(f"Using direct file path: {media_path}")
|
||||
path_value = Path(str(path_arg).strip())
|
||||
# If there is no piped result, treat -path as the source file (existing behavior)
|
||||
if result is None:
|
||||
if not path_value.exists():
|
||||
log(f"❌ File not found: {path_value}")
|
||||
return 1
|
||||
result = {"target": str(path_value), "origin": "wild"}
|
||||
log(f"Using direct file path: {path_value}")
|
||||
else:
|
||||
# Piped result present: treat -path as destination (export)
|
||||
if not path_value.exists():
|
||||
try:
|
||||
path_value.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
log(f"❌ Cannot create destination directory {path_value}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
location = str(path_value)
|
||||
|
||||
# Get location from parsed args - now uses SharedArgs.STORAGE so key is "storage"
|
||||
location = parsed.get("storage")
|
||||
@@ -714,7 +758,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 1
|
||||
|
||||
log(f"Moving to local path: {destination_root}", file=sys.stderr)
|
||||
exit_code, dest_path = _handle_local_transfer(media_path, destination_root, result, config)
|
||||
exit_code, dest_path = _handle_local_transfer(media_path, destination_root, result, config, export_mode=True)
|
||||
|
||||
# After successful local transfer, emit result for pipeline continuation
|
||||
if exit_code == 0 and dest_path:
|
||||
|
||||
@@ -79,6 +79,31 @@ def add(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except Exception as exc:
|
||||
log(f"Hydrus add-note failed: {exc}")
|
||||
return 1
|
||||
|
||||
# Refresh notes view if we're operating on the currently selected subject
|
||||
try:
|
||||
from cmdlets import get_note as get_note_cmd # type: ignore
|
||||
except Exception:
|
||||
get_note_cmd = None
|
||||
if get_note_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
subj_hashes = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
get_note_cmd.get_notes(subject, ["-hash", hash_hex], config)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ctx.emit(f"Added note '{name}' ({len(text)} chars)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -145,6 +145,49 @@ def _resolve_king_reference(king_arg: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_relationship_view_if_current(target_hash: Optional[str], target_path: Optional[str], other: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""If the current subject matches the target, refresh relationships via get-relationship."""
|
||||
try:
|
||||
from cmdlets import get_relationship as get_rel_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
return
|
||||
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
|
||||
target_hashes = [norm(v) for v in [target_hash, other] if v]
|
||||
target_paths = [norm(v) for v in [target_path, other] if v]
|
||||
|
||||
subj_hashes: list[str] = []
|
||||
subj_paths: list[str] = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
|
||||
is_match = False
|
||||
if target_hashes and any(h in subj_hashes for h in target_hashes):
|
||||
is_match = True
|
||||
if target_paths and any(p in subj_paths for p in target_paths):
|
||||
is_match = True
|
||||
if not is_match:
|
||||
return
|
||||
|
||||
refresh_args: list[str] = []
|
||||
if target_hash:
|
||||
refresh_args.extend(["-hash", target_hash])
|
||||
get_rel_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@register(["add-relationship", "add-rel"]) # primary name and alias
|
||||
def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Associate file relationships in Hydrus.
|
||||
@@ -253,6 +296,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {king_hash}",
|
||||
file=sys.stderr
|
||||
)
|
||||
_refresh_relationship_view_if_current(file_hash, file_path_from_result, king_hash, config)
|
||||
except Exception as exc:
|
||||
log(f"Failed to set relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -280,6 +324,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
f"[add-relationship] Set {rel_type} relationship: {file_hash} <-> {existing_king}",
|
||||
file=sys.stderr
|
||||
)
|
||||
_refresh_relationship_view_if_current(file_hash, file_path_from_result, existing_king, config)
|
||||
except Exception as exc:
|
||||
log(f"Failed to set relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -300,6 +345,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
with LocalLibrarySearchOptimizer(local_storage_path) as db:
|
||||
db.set_relationship(file_path_obj, king_file_path, rel_type)
|
||||
log(f"Set {rel_type} relationship: {file_path_obj.name} -> {king_file_path.name}", file=sys.stderr)
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), str(king_file_path), config)
|
||||
else:
|
||||
log(f"King file not found or invalid: {king_hash}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -323,6 +369,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
with LocalLibrarySearchOptimizer(local_storage_path) as db:
|
||||
db.set_relationship(file_path_obj, Path(king_path), rel_type)
|
||||
log(f"Set {rel_type} relationship: {file_path_obj.name} -> {Path(king_path).name}", file=sys.stderr)
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), str(king_path), config)
|
||||
except Exception as exc:
|
||||
log(f"Failed to set relationship: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -28,6 +28,171 @@ def _extract_title_tag(tags: List[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
|
||||
"""Update result object/dict title fields and columns in-place."""
|
||||
if not title_value:
|
||||
return
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.title = title_value
|
||||
# Update columns if present (Title column assumed index 0)
|
||||
if hasattr(res, "columns") and isinstance(res.columns, list) and res.columns:
|
||||
label, *_ = res.columns[0]
|
||||
if str(label).lower() == "title":
|
||||
res.columns[0] = (res.columns[0][0], title_value)
|
||||
elif isinstance(res, dict):
|
||||
res["title"] = title_value
|
||||
cols = res.get("columns")
|
||||
if isinstance(cols, list):
|
||||
updated = []
|
||||
changed = False
|
||||
for col in cols:
|
||||
if isinstance(col, tuple) and len(col) == 2:
|
||||
label, val = col
|
||||
if str(label).lower() == "title":
|
||||
updated.append((label, title_value))
|
||||
changed = True
|
||||
else:
|
||||
updated.append(col)
|
||||
else:
|
||||
updated.append(col)
|
||||
if changed:
|
||||
res["columns"] = updated
|
||||
|
||||
|
||||
def _matches_target(item: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> bool:
|
||||
"""Determine whether a result item refers to the given hash/path target."""
|
||||
hydrus_hash_l = hydrus_hash.lower() if hydrus_hash else None
|
||||
file_hash_l = file_hash.lower() if file_hash else None
|
||||
file_path_l = file_path.lower() if file_path else None
|
||||
|
||||
def norm(val: Any) -> Optional[str]:
|
||||
return str(val).lower() if val is not None else None
|
||||
|
||||
if isinstance(item, dict):
|
||||
hashes = [
|
||||
norm(item.get("hydrus_hash")),
|
||||
norm(item.get("hash")),
|
||||
norm(item.get("hash_hex")),
|
||||
norm(item.get("file_hash")),
|
||||
]
|
||||
paths = [
|
||||
norm(item.get("path")),
|
||||
norm(item.get("file_path")),
|
||||
norm(item.get("target")),
|
||||
]
|
||||
else:
|
||||
hashes = [
|
||||
norm(getattr(item, "hydrus_hash", None)),
|
||||
norm(getattr(item, "hash_hex", None)),
|
||||
norm(getattr(item, "file_hash", None)),
|
||||
]
|
||||
paths = [
|
||||
norm(getattr(item, "path", None)),
|
||||
norm(getattr(item, "file_path", None)),
|
||||
norm(getattr(item, "target", None)),
|
||||
]
|
||||
|
||||
if hydrus_hash_l and hydrus_hash_l in hashes:
|
||||
return True
|
||||
if file_hash_l and file_hash_l in hashes:
|
||||
return True
|
||||
if file_path_l and file_path_l in paths:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _update_item_title_fields(item: Any, new_title: str) -> None:
|
||||
"""Mutate an item to reflect a new title in plain fields and columns."""
|
||||
if isinstance(item, models.PipeObject):
|
||||
item.title = new_title
|
||||
if hasattr(item, "columns") and isinstance(item.columns, list) and item.columns:
|
||||
label, *_ = item.columns[0]
|
||||
if str(label).lower() == "title":
|
||||
item.columns[0] = (label, new_title)
|
||||
elif isinstance(item, dict):
|
||||
item["title"] = new_title
|
||||
cols = item.get("columns")
|
||||
if isinstance(cols, list):
|
||||
updated_cols = []
|
||||
changed = False
|
||||
for col in cols:
|
||||
if isinstance(col, tuple) and len(col) == 2:
|
||||
label, val = col
|
||||
if str(label).lower() == "title":
|
||||
updated_cols.append((label, new_title))
|
||||
changed = True
|
||||
else:
|
||||
updated_cols.append(col)
|
||||
else:
|
||||
updated_cols.append(col)
|
||||
if changed:
|
||||
item["columns"] = updated_cols
|
||||
|
||||
|
||||
def _refresh_result_table_title(new_title: str, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str]) -> None:
|
||||
"""Refresh the cached result table with an updated title and redisplay it."""
|
||||
try:
|
||||
last_table = ctx.get_last_result_table()
|
||||
items = ctx.get_last_result_items()
|
||||
if not last_table or not items:
|
||||
return
|
||||
|
||||
updated_items = []
|
||||
match_found = False
|
||||
for item in items:
|
||||
try:
|
||||
if _matches_target(item, hydrus_hash, file_hash, file_path):
|
||||
_update_item_title_fields(item, new_title)
|
||||
match_found = True
|
||||
except Exception:
|
||||
pass
|
||||
updated_items.append(item)
|
||||
|
||||
if not match_found:
|
||||
return
|
||||
|
||||
from result_table import ResultTable # Local import to avoid circular dependency
|
||||
|
||||
new_table = ResultTable(getattr(last_table, "title", ""), title_width=getattr(last_table, "title_width", 80), max_columns=getattr(last_table, "max_columns", None))
|
||||
if getattr(last_table, "source_command", None):
|
||||
new_table.set_source_command(last_table.source_command, getattr(last_table, "source_args", []))
|
||||
|
||||
for item in updated_items:
|
||||
new_table.add_result(item)
|
||||
|
||||
ctx.set_last_result_table_preserve_history(new_table, updated_items)
|
||||
ctx.set_last_result_table_overlay(new_table, updated_items)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _refresh_tags_view(res: Any, hydrus_hash: Optional[str], file_hash: Optional[str], file_path: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
|
||||
try:
|
||||
from cmdlets import get_tag as get_tag_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
target_hash = hydrus_hash or file_hash
|
||||
refresh_args: List[str] = []
|
||||
if target_hash:
|
||||
refresh_args = ["-hash", target_hash]
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject and _matches_target(subject, hydrus_hash, file_hash, file_path):
|
||||
get_tag_cmd._run(subject, refresh_args, config)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if target_hash:
|
||||
try:
|
||||
get_tag_cmd._run(res, refresh_args, config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
@register(["add-tag", "add-tags"])
|
||||
@@ -148,7 +313,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Tags ARE provided - append them to each result and write sidecar files or add to Hydrus
|
||||
sidecar_count = 0
|
||||
removed_tags: List[str] = []
|
||||
total_new_tags = 0
|
||||
total_modified = 0
|
||||
for res in results:
|
||||
# Handle both dict and PipeObject formats
|
||||
file_path = None
|
||||
@@ -180,9 +346,17 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
hydrus_hash = file_hash
|
||||
if not storage_source and hydrus_hash and not file_path:
|
||||
storage_source = 'hydrus'
|
||||
# If we have a file path but no storage source, assume local to avoid sidecar spam
|
||||
if not storage_source and file_path:
|
||||
storage_source = 'local'
|
||||
else:
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
|
||||
original_tags_snapshot = list(existing_tags)
|
||||
original_title = _extract_title_tag(original_tags_snapshot)
|
||||
removed_tags: List[str] = []
|
||||
|
||||
# Apply hash override if provided
|
||||
if hash_override:
|
||||
@@ -239,35 +413,47 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if new_tag not in existing_tags:
|
||||
existing_tags.append(new_tag)
|
||||
|
||||
# Compute new tags relative to original
|
||||
new_tags_added = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
total_new_tags += len(new_tags_added)
|
||||
|
||||
# Update the result's tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = existing_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = existing_tags
|
||||
|
||||
# If a title: tag was added, update the in-memory title so downstream display reflects it immediately
|
||||
# If a title: tag was added, update the in-memory title and columns so downstream display reflects it immediately
|
||||
title_value = _extract_title_tag(existing_tags)
|
||||
if title_value:
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.title = title_value
|
||||
elif isinstance(res, dict):
|
||||
res['title'] = title_value
|
||||
_apply_title_to_result(res, title_value)
|
||||
|
||||
final_tags = existing_tags
|
||||
|
||||
# Determine where to add tags: Hydrus, local DB, or sidecar
|
||||
if storage_source and storage_source.lower() == 'hydrus':
|
||||
# Add tags to Hydrus using the API
|
||||
target_hash = hydrus_hash or file_hash
|
||||
if target_hash:
|
||||
try:
|
||||
log(f"[add_tags] Adding {len(existing_tags)} tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
|
||||
tags_to_send = [t for t in existing_tags if isinstance(t, str) and t.lower() not in original_tags_lower]
|
||||
hydrus_client = hydrus_wrapper.get_client(config)
|
||||
hydrus_client.add_tags(target_hash, existing_tags, "my tags")
|
||||
service_name = hydrus_wrapper.get_tag_service_name(config)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] Adding {len(tags_to_send)} new tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
|
||||
hydrus_client.add_tags(target_hash, tags_to_send, service_name)
|
||||
else:
|
||||
log(f"[add_tags] No new tags to add for Hydrus file: {target_hash}", file=sys.stderr)
|
||||
# Delete old namespace tags we replaced (e.g., previous title:)
|
||||
if removed_tags:
|
||||
unique_removed = sorted(set(removed_tags))
|
||||
hydrus_client.delete_tags(target_hash, unique_removed, "my tags")
|
||||
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
|
||||
hydrus_client.delete_tags(target_hash, unique_removed, service_name)
|
||||
if tags_to_send:
|
||||
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
|
||||
elif removed_tags:
|
||||
log(f"[add_tags] ✓ Removed {len(unique_removed)} tag(s) from Hydrus", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if tags_to_send or removed_tags:
|
||||
total_modified += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to add tags to Hydrus: {e}", file=sys.stderr)
|
||||
else:
|
||||
@@ -278,10 +464,25 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
path_obj = Path(file_path)
|
||||
with LocalLibraryDB(library_root) as db:
|
||||
db.save_tags(Path(file_path), existing_tags)
|
||||
log(f"[add_tags] Saved {len(existing_tags)} tag(s) to local DB", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
db.save_tags(path_obj, existing_tags)
|
||||
# Reload tags to reflect DB state (preserves auto-title logic)
|
||||
refreshed_tags = db.get_tags(path_obj) or existing_tags
|
||||
# Recompute title from refreshed tags for accurate display
|
||||
refreshed_title = _extract_title_tag(refreshed_tags)
|
||||
if refreshed_title:
|
||||
_apply_title_to_result(res, refreshed_title)
|
||||
res_tags = refreshed_tags or existing_tags
|
||||
if isinstance(res, models.PipeObject):
|
||||
res.extra['tags'] = res_tags
|
||||
elif isinstance(res, dict):
|
||||
res['tags'] = res_tags
|
||||
log(f"[add_tags] Added {len(new_tags_added)} new tag(s); {len(res_tags)} total tag(s) stored locally", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
if new_tags_added or removed_tags:
|
||||
total_modified += 1
|
||||
final_tags = res_tags
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to save tags to local DB: {e}", file=sys.stderr)
|
||||
else:
|
||||
@@ -289,19 +490,24 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
else:
|
||||
log(f"[add_tags] Warning: No file path for local storage, skipping", file=sys.stderr)
|
||||
else:
|
||||
# For other storage types or unknown sources, write sidecar file if we have a file path
|
||||
if file_path:
|
||||
try:
|
||||
sidecar_path = write_sidecar(Path(file_path), existing_tags, [], file_hash)
|
||||
log(f"[add_tags] Wrote {len(existing_tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
|
||||
sidecar_count += 1
|
||||
except Exception as e:
|
||||
log(f"[add_tags] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
|
||||
# For other storage types or unknown sources, avoid writing sidecars to reduce clutter
|
||||
# (local/hydrus are handled above).
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
# If title changed, refresh the cached result table so the display reflects the new name
|
||||
final_title = _extract_title_tag(final_tags)
|
||||
if final_title and (not original_title or final_title.lower() != original_title.lower()):
|
||||
_refresh_result_table_title(final_title, hydrus_hash or file_hash, file_hash, file_path)
|
||||
|
||||
# If tags changed, refresh tag view via get-tag (prefer current subject; fall back to hash refresh)
|
||||
if new_tags_added or removed_tags:
|
||||
_refresh_tags_view(res, hydrus_hash, file_hash, file_path, config)
|
||||
|
||||
# Emit the modified result
|
||||
ctx.emit(res)
|
||||
|
||||
log(f"[add_tags] Processed {len(results)} result(s)", file=sys.stderr)
|
||||
log(f"[add_tags] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
|
||||
@@ -13,6 +13,7 @@ from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from helper.logger import log
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from helper.logger import debug
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="add-url",
|
||||
@@ -124,6 +125,39 @@ def add(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 1
|
||||
|
||||
if success:
|
||||
# If we just mutated the currently displayed item, refresh URLs via get-url
|
||||
try:
|
||||
from cmdlets import get_url as get_url_cmd # type: ignore
|
||||
except Exception:
|
||||
get_url_cmd = None
|
||||
if get_url_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
target_path = norm(file_path) if 'file_path' in locals() else None
|
||||
subj_hashes = []
|
||||
subj_paths = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
is_match = True
|
||||
if target_path and target_path in subj_paths:
|
||||
is_match = True
|
||||
if is_match:
|
||||
refresh_args: list[str] = []
|
||||
if hash_hex:
|
||||
refresh_args.extend(["-hash", hash_hex])
|
||||
get_url_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
debug("URL refresh skipped (error)")
|
||||
return 0
|
||||
|
||||
if not hash_hex and not file_path:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import Any, Dict, Sequence
|
||||
import json
|
||||
|
||||
import pipeline as ctx
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from helper.logger import log
|
||||
@@ -75,5 +76,30 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except Exception as exc:
|
||||
log(f"Hydrus delete-note failed: {exc}")
|
||||
return 1
|
||||
|
||||
# Refresh notes view if we're operating on the current subject
|
||||
try:
|
||||
from cmdlets import get_note as get_note_cmd # type: ignore
|
||||
except Exception:
|
||||
get_note_cmd = None
|
||||
if get_note_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
subj_hashes = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
get_note_cmd.get_notes(subject, ["-hash", hash_hex], config)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log(f"Deleted note '{name}'")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -15,6 +15,49 @@ from helper.local_library import LocalLibrarySearchOptimizer
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
def _refresh_relationship_view_if_current(target_hash: Optional[str], target_path: Optional[str], other: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""If the current subject matches the target, refresh relationships via get-relationship."""
|
||||
try:
|
||||
from cmdlets import get_relationship as get_rel_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
return
|
||||
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
|
||||
target_hashes = [norm(v) for v in [target_hash, other] if v]
|
||||
target_paths = [norm(v) for v in [target_path, other] if v]
|
||||
|
||||
subj_hashes: list[str] = []
|
||||
subj_paths: list[str] = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
|
||||
is_match = False
|
||||
if target_hashes and any(h in subj_hashes for h in target_hashes):
|
||||
is_match = True
|
||||
if target_paths and any(p in subj_paths for p in target_paths):
|
||||
is_match = True
|
||||
if not is_match:
|
||||
return
|
||||
|
||||
refresh_args: list[str] = []
|
||||
if target_hash:
|
||||
refresh_args.extend(["-hash", target_hash])
|
||||
get_rel_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Delete relationships from files.
|
||||
|
||||
@@ -137,6 +180,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
""", (file_id, json.dumps(relationships) if relationships else None))
|
||||
|
||||
db.db.connection.commit()
|
||||
_refresh_relationship_view_if_current(None, str(file_path_obj), None, config)
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
from pathlib import Path
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -12,6 +13,49 @@ from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments
|
||||
from helper.logger import debug, log
|
||||
|
||||
|
||||
def _refresh_tag_view_if_current(hash_hex: str | None, file_path: str | None, config: Dict[str, Any]) -> None:
|
||||
"""If the current subject matches the target, refresh tags via get-tag."""
|
||||
try:
|
||||
from cmdlets import get_tag as get_tag_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is None:
|
||||
return
|
||||
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
target_path = norm(file_path) if file_path else None
|
||||
|
||||
subj_hashes: list[str] = []
|
||||
subj_paths: list[str] = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
is_match = True
|
||||
if target_path and target_path in subj_paths:
|
||||
is_match = True
|
||||
if not is_match:
|
||||
return
|
||||
|
||||
refresh_args: list[str] = []
|
||||
if hash_hex:
|
||||
refresh_args.extend(["-hash", hash_hex])
|
||||
get_tag_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="delete-tags",
|
||||
summary="Remove tags from a Hydrus file.",
|
||||
@@ -220,12 +264,69 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
|
||||
if not tags:
|
||||
return False
|
||||
|
||||
def _fetch_existing_tags() -> list[str]:
|
||||
existing: list[str] = []
|
||||
# Prefer local DB when we have a path and not explicitly hydrus
|
||||
if file_path and (source == "local" or (source != "hydrus" and not hash_hex)):
|
||||
try:
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from config import get_local_storage_path
|
||||
path_obj = Path(file_path)
|
||||
local_root = get_local_storage_path(config) or path_obj.parent
|
||||
with LocalLibraryDB(local_root) as db:
|
||||
existing = db.get_tags(path_obj) or []
|
||||
except Exception:
|
||||
existing = []
|
||||
elif hash_hex:
|
||||
try:
|
||||
client = hydrus_wrapper.get_client(config)
|
||||
payload = client.fetch_file_metadata(
|
||||
hashes=[hash_hex],
|
||||
include_service_keys_to_tags=True,
|
||||
include_file_urls=False,
|
||||
)
|
||||
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||
meta = items[0] if isinstance(items, list) and items else None
|
||||
if isinstance(meta, dict):
|
||||
tags_payload = meta.get("tags")
|
||||
if isinstance(tags_payload, dict):
|
||||
seen: set[str] = set()
|
||||
for svc_data in tags_payload.values():
|
||||
if not isinstance(svc_data, dict):
|
||||
continue
|
||||
display = svc_data.get("display_tags")
|
||||
if isinstance(display, list):
|
||||
for t in display:
|
||||
if isinstance(t, (str, bytes)):
|
||||
val = str(t).strip()
|
||||
if val and val not in seen:
|
||||
seen.add(val)
|
||||
existing.append(val)
|
||||
storage = svc_data.get("storage_tags")
|
||||
if isinstance(storage, dict):
|
||||
current_list = storage.get("0") or storage.get(0)
|
||||
if isinstance(current_list, list):
|
||||
for t in current_list:
|
||||
if isinstance(t, (str, bytes)):
|
||||
val = str(t).strip()
|
||||
if val and val not in seen:
|
||||
seen.add(val)
|
||||
existing.append(val)
|
||||
except Exception:
|
||||
existing = []
|
||||
return existing
|
||||
|
||||
# Safety: block deleting title: without replacement to avoid untitled files
|
||||
# Safety: only block if this deletion would remove the final title tag
|
||||
title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")]
|
||||
if title_tags:
|
||||
log("Cannot delete title: tag without replacement. Use add-tag \"title:new title\" instead.", file=sys.stderr)
|
||||
return False
|
||||
existing_tags = _fetch_existing_tags()
|
||||
current_titles = [t for t in existing_tags if isinstance(t, str) and t.lower().startswith("title:")]
|
||||
del_title_set = {t.lower() for t in title_tags}
|
||||
remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
|
||||
if current_titles and not remaining_titles:
|
||||
log("Cannot delete the last title: tag. Add a replacement title first (add-tag \"title:new title\").", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if not hash_hex and not file_path:
|
||||
log("Item does not include a hash or file path")
|
||||
@@ -253,6 +354,7 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
with LocalLibraryDB(local_root) as db:
|
||||
db.remove_tags(path_obj, tags)
|
||||
debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)")
|
||||
_refresh_tag_view_if_current(hash_hex, file_path, config)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
@@ -276,6 +378,7 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
|
||||
|
||||
preview = hash_hex[:12] + ('…' if len(hash_hex) > 12 else '')
|
||||
debug(f"Removed {len(tags)} tag(s) from {preview} via '{service_name}'.")
|
||||
_refresh_tag_view_if_current(hash_hex, None, config)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from . import register
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from helper.logger import log
|
||||
from helper.logger import debug, log
|
||||
from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
import pipeline as ctx
|
||||
@@ -152,5 +152,43 @@ def _delete_single(result: Any, url: str, override_hash: str | None, config: Dic
|
||||
success = True
|
||||
except Exception as exc:
|
||||
log(f"Hydrus del-url failed: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
if success:
|
||||
try:
|
||||
from cmdlets import get_url as get_url_cmd # type: ignore
|
||||
except Exception:
|
||||
get_url_cmd = None
|
||||
if get_url_cmd:
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
if subject is not None:
|
||||
def norm(val: Any) -> str:
|
||||
return str(val).lower()
|
||||
|
||||
target_hash = norm(hash_hex) if hash_hex else None
|
||||
target_path = norm(file_path) if file_path else None
|
||||
|
||||
subj_hashes = []
|
||||
subj_paths = []
|
||||
if isinstance(subject, dict):
|
||||
subj_hashes = [norm(v) for v in [subject.get("hydrus_hash"), subject.get("hash"), subject.get("hash_hex"), subject.get("file_hash")] if v]
|
||||
subj_paths = [norm(v) for v in [subject.get("file_path"), subject.get("path"), subject.get("target")] if v]
|
||||
else:
|
||||
subj_hashes = [norm(getattr(subject, f, None)) for f in ("hydrus_hash", "hash", "hash_hex", "file_hash") if getattr(subject, f, None)]
|
||||
subj_paths = [norm(getattr(subject, f, None)) for f in ("file_path", "path", "target") if getattr(subject, f, None)]
|
||||
|
||||
is_match = False
|
||||
if target_hash and target_hash in subj_hashes:
|
||||
is_match = True
|
||||
if target_path and target_path in subj_paths:
|
||||
is_match = True
|
||||
|
||||
if is_match:
|
||||
refresh_args: list[str] = []
|
||||
if hash_hex:
|
||||
refresh_args.extend(["-hash", hash_hex])
|
||||
get_url_cmd._run(subject, refresh_args, config)
|
||||
except Exception:
|
||||
debug("URL refresh skipped (error)")
|
||||
|
||||
return success
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
import pipeline as ctx
|
||||
from helper import hydrus
|
||||
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, LocalLibraryDB
|
||||
from ._shared import normalize_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args
|
||||
from ._shared import normalize_hash, looks_like_hash, Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ def _emit_tags_as_table(
|
||||
service_name: Optional[str] = None,
|
||||
config: Dict[str, Any] = None,
|
||||
item_title: Optional[str] = None,
|
||||
file_path: Optional[str] = None
|
||||
file_path: Optional[str] = None,
|
||||
subject: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""Emit tags as TagItem objects and display via ResultTable.
|
||||
|
||||
@@ -144,9 +145,9 @@ def _emit_tags_as_table(
|
||||
# Use overlay mode so it doesn't push the previous search to history stack
|
||||
# This makes get-tag behave like a transient view
|
||||
try:
|
||||
ctx.set_last_result_table_overlay(table, tag_items)
|
||||
ctx.set_last_result_table_overlay(table, tag_items, subject)
|
||||
except AttributeError:
|
||||
ctx.set_last_result_table(table, tag_items)
|
||||
ctx.set_last_result_table(table, tag_items, subject)
|
||||
# Note: CLI will handle displaying the table via ResultTable formatting
|
||||
def _summarize_tags(tags_list: List[str], limit: int = 8) -> str:
|
||||
"""Create a summary of tags for display."""
|
||||
@@ -443,7 +444,10 @@ def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional
|
||||
def _extract_scrapable_identifiers(tags_list: List[str]) -> Dict[str, str]:
|
||||
"""Extract scrapable identifiers from tags."""
|
||||
identifiers = {}
|
||||
scrapable_prefixes = {'openlibrary', 'isbn_10', 'isbn', 'musicbrainz', 'musicbrainzalbum', 'imdb', 'tmdb', 'tvdb'}
|
||||
scrapable_prefixes = {
|
||||
'openlibrary', 'isbn', 'isbn_10', 'isbn_13',
|
||||
'musicbrainz', 'musicbrainzalbum', 'imdb', 'tmdb', 'tvdb'
|
||||
}
|
||||
|
||||
for tag in tags_list:
|
||||
if not isinstance(tag, str) or ':' not in tag:
|
||||
@@ -453,9 +457,18 @@ def _extract_scrapable_identifiers(tags_list: List[str]) -> Dict[str, str]:
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
|
||||
key = parts[0].strip().lower()
|
||||
key_raw = parts[0].strip().lower()
|
||||
key = key_raw.replace('-', '_')
|
||||
if key == 'isbn10':
|
||||
key = 'isbn_10'
|
||||
elif key == 'isbn13':
|
||||
key = 'isbn_13'
|
||||
value = parts[1].strip()
|
||||
|
||||
# Normalize ISBN values by removing hyphens for API friendliness
|
||||
if key.startswith('isbn'):
|
||||
value = value.replace('-', '')
|
||||
|
||||
if key in scrapable_prefixes and value:
|
||||
identifiers[key] = value
|
||||
|
||||
@@ -965,8 +978,8 @@ def _perform_scraping(tags_list: List[str]) -> List[str]:
|
||||
if olid:
|
||||
log(f"Scraping OpenLibrary: {olid}")
|
||||
new_tags.extend(_scrape_openlibrary_metadata(olid))
|
||||
elif 'isbn_10' in identifiers or 'isbn' in identifiers:
|
||||
isbn = identifiers.get('isbn_10') or identifiers.get('isbn')
|
||||
elif 'isbn_13' in identifiers or 'isbn_10' in identifiers or 'isbn' in identifiers:
|
||||
isbn = identifiers.get('isbn_13') or identifiers.get('isbn_10') or identifiers.get('isbn')
|
||||
if isbn:
|
||||
log(f"Scraping ISBN: {isbn}")
|
||||
new_tags.extend(_scrape_isbn_metadata(isbn))
|
||||
@@ -991,13 +1004,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
Usage:
|
||||
get-tag [-hash <sha256>] [--store <key>] [--emit]
|
||||
get-tag -scrape <url>
|
||||
get-tag -scrape <url|provider>
|
||||
|
||||
Options:
|
||||
-hash <sha256>: Override hash to use instead of result's hash_hex
|
||||
--store <key>: Store result to this key for pipeline
|
||||
--emit: Emit result without interactive prompt (quiet mode)
|
||||
-scrape <url>: Scrape metadata from URL (returns tags as JSON)
|
||||
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks)
|
||||
"""
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
@@ -1008,13 +1021,26 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
# Parse arguments using shared parser
|
||||
parsed_args = parse_cmdlet_args(args, CMDLET)
|
||||
|
||||
|
||||
# Detect if -scrape flag was provided without a value (parse_cmdlet_args skips missing values)
|
||||
scrape_flag_present = any(str(arg).lower() in {"-scrape", "--scrape"} for arg in args)
|
||||
|
||||
# Extract values
|
||||
hash_override = normalize_hash(parsed_args.get("hash"))
|
||||
hash_override_raw = parsed_args.get("hash")
|
||||
hash_override = normalize_hash(hash_override_raw)
|
||||
store_key = parsed_args.get("store")
|
||||
emit_requested = parsed_args.get("emit", False)
|
||||
scrape_url = parsed_args.get("scrape")
|
||||
scrape_requested = scrape_url is not None
|
||||
scrape_requested = scrape_flag_present or scrape_url is not None
|
||||
|
||||
if hash_override_raw is not None:
|
||||
if not hash_override or not looks_like_hash(hash_override):
|
||||
log("Invalid hash format: expected 64 hex characters", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if scrape_requested and (not scrape_url or str(scrape_url).strip() == ""):
|
||||
log("-scrape requires a URL or provider name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Handle URL or provider scraping mode
|
||||
if scrape_requested and scrape_url:
|
||||
@@ -1041,18 +1067,51 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
log(f"Unknown metadata provider: {scrape_url}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Determine query from title on the result or filename
|
||||
# Prefer identifier tags (ISBN/OLID/etc.) when available; fallback to title/filename
|
||||
identifier_tags: List[str] = []
|
||||
result_tags = get_field(result, "tags", None)
|
||||
if isinstance(result_tags, list):
|
||||
identifier_tags = [str(t) for t in result_tags if isinstance(t, (str, bytes))]
|
||||
|
||||
# Try local sidecar if no tags present on result
|
||||
if not identifier_tags:
|
||||
file_path = get_field(result, "target", None) or get_field(result, "path", None) or get_field(result, "file_path", None) or get_field(result, "filename", None)
|
||||
if isinstance(file_path, str) and file_path and not file_path.lower().startswith(("http://", "https://")):
|
||||
try:
|
||||
media_path = Path(str(file_path))
|
||||
if media_path.exists():
|
||||
tags_from_sidecar = read_sidecar(media_path)
|
||||
if isinstance(tags_from_sidecar, list):
|
||||
identifier_tags = [str(t) for t in tags_from_sidecar if isinstance(t, (str, bytes))]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
identifiers = _extract_scrapable_identifiers(identifier_tags)
|
||||
identifier_query: Optional[str] = None
|
||||
if identifiers:
|
||||
if provider.name in {"openlibrary", "googlebooks", "google"}:
|
||||
identifier_query = identifiers.get("isbn_13") or identifiers.get("isbn_10") or identifiers.get("isbn") or identifiers.get("openlibrary")
|
||||
elif provider.name == "itunes":
|
||||
identifier_query = identifiers.get("musicbrainz") or identifiers.get("musicbrainzalbum")
|
||||
|
||||
# Determine query from identifier first, else title on the result or filename
|
||||
title_hint = get_field(result, "title", None) or get_field(result, "name", None)
|
||||
if not title_hint:
|
||||
file_path = get_field(result, "path", None) or get_field(result, "filename", None)
|
||||
if file_path:
|
||||
title_hint = Path(str(file_path)).stem
|
||||
|
||||
if not title_hint:
|
||||
log("No title available to search for metadata", file=sys.stderr)
|
||||
query_hint = identifier_query or title_hint
|
||||
if not query_hint:
|
||||
log("No title or identifier available to search for metadata", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
items = provider.search(title_hint, limit=10)
|
||||
if identifier_query:
|
||||
log(f"Using identifier for metadata search: {identifier_query}")
|
||||
else:
|
||||
log(f"Using title for metadata search: {query_hint}")
|
||||
|
||||
items = provider.search(query_hint, limit=10)
|
||||
if not items:
|
||||
log("No metadata results found", file=sys.stderr)
|
||||
return 1
|
||||
@@ -1212,11 +1271,46 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Always output to ResultTable (pipeline mode only)
|
||||
# Extract title for table header
|
||||
item_title = get_field(result, "title", None) or get_field(result, "name", None) or get_field(result, "filename", None)
|
||||
|
||||
# Build a subject payload representing the file whose tags are being shown
|
||||
subject_origin = get_field(result, "origin", None) or get_field(result, "source", None) or source
|
||||
subject_payload: Dict[str, Any] = {
|
||||
"tags": list(current),
|
||||
"title": item_title,
|
||||
"name": item_title,
|
||||
"origin": subject_origin,
|
||||
"source": subject_origin,
|
||||
"storage_source": subject_origin,
|
||||
"service_name": service_name,
|
||||
"extra": {
|
||||
"tags": list(current),
|
||||
"storage_source": subject_origin,
|
||||
"hydrus_hash": hash_hex,
|
||||
},
|
||||
}
|
||||
if hash_hex:
|
||||
subject_payload.update({
|
||||
"hash": hash_hex,
|
||||
"hash_hex": hash_hex,
|
||||
"file_hash": hash_hex,
|
||||
"hydrus_hash": hash_hex,
|
||||
})
|
||||
if local_path:
|
||||
try:
|
||||
path_text = str(local_path)
|
||||
subject_payload.update({
|
||||
"file_path": path_text,
|
||||
"path": path_text,
|
||||
"target": path_text,
|
||||
})
|
||||
subject_payload["extra"]["file_path"] = path_text
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if source == "hydrus":
|
||||
_emit_tags_as_table(current, hash_hex=hash_hex, source="hydrus", service_name=service_name, config=config, item_title=item_title)
|
||||
_emit_tags_as_table(current, hash_hex=hash_hex, source="hydrus", service_name=service_name, config=config, item_title=item_title, subject=subject_payload)
|
||||
else:
|
||||
_emit_tags_as_table(current, hash_hex=hash_hex, source="local", service_name=None, config=config, item_title=item_title, file_path=str(local_path) if local_path else None)
|
||||
_emit_tags_as_table(current, hash_hex=hash_hex, source="local", service_name=None, config=config, item_title=item_title, file_path=str(local_path) if local_path else None, subject=subject_payload)
|
||||
|
||||
# If emit requested or store key provided, emit payload
|
||||
if emit_mode:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Dict, Sequence, List, Optional, Tuple, Callable
|
||||
from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -135,45 +136,46 @@ STORAGE_ORIGINS = {"local", "hydrus", "debrid"}
|
||||
|
||||
|
||||
def _ensure_storage_columns(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach Title/Store columns for storage-origin results to keep CLI display compact."""
|
||||
origin_value = str(payload.get("origin") or payload.get("source") or "").lower()
|
||||
if origin_value not in STORAGE_ORIGINS:
|
||||
return payload
|
||||
title = payload.get("title") or payload.get("name") or payload.get("target") or payload.get("path") or "Result"
|
||||
store_label = payload.get("origin") or payload.get("source") or origin_value
|
||||
|
||||
# Handle extension
|
||||
extension = payload.get("ext", "")
|
||||
if not extension and title:
|
||||
path_obj = Path(str(title))
|
||||
if path_obj.suffix:
|
||||
extension = path_obj.suffix.lstrip('.')
|
||||
title = path_obj.stem
|
||||
"""Attach Title/Store columns for storage-origin results to keep CLI display compact."""
|
||||
origin_value = str(payload.get("origin") or payload.get("source") or "").lower()
|
||||
if origin_value not in STORAGE_ORIGINS:
|
||||
return payload
|
||||
|
||||
# Handle size
|
||||
size_val = payload.get("size") or payload.get("size_bytes")
|
||||
size_str = ""
|
||||
if size_val:
|
||||
try:
|
||||
size_bytes = int(size_val)
|
||||
size_mb = size_bytes / (1024 * 1024)
|
||||
size_str = f"{int(size_mb)} MB"
|
||||
except (ValueError, TypeError):
|
||||
size_str = str(size_val)
|
||||
title = payload.get("title") or payload.get("name") or payload.get("target") or payload.get("path") or "Result"
|
||||
store_label = payload.get("origin") or payload.get("source") or origin_value
|
||||
|
||||
normalized = dict(payload)
|
||||
normalized["columns"] = [
|
||||
("Title", str(title)),
|
||||
("Ext", str(extension)),
|
||||
("Store", str(store_label)),
|
||||
("Size", str(size_str))
|
||||
]
|
||||
return normalized
|
||||
# Handle extension
|
||||
extension = payload.get("ext", "")
|
||||
if not extension and title:
|
||||
path_obj = Path(str(title))
|
||||
if path_obj.suffix:
|
||||
extension = path_obj.suffix.lstrip('.')
|
||||
title = path_obj.stem
|
||||
|
||||
# Handle size as integer MB (header will include units)
|
||||
size_val = payload.get("size") or payload.get("size_bytes")
|
||||
size_str = ""
|
||||
if size_val is not None:
|
||||
try:
|
||||
size_bytes = int(size_val)
|
||||
size_mb = int(size_bytes / (1024 * 1024))
|
||||
size_str = str(size_mb)
|
||||
except (ValueError, TypeError):
|
||||
size_str = str(size_val)
|
||||
|
||||
normalized = dict(payload)
|
||||
normalized["columns"] = [
|
||||
("Title", str(title)),
|
||||
("Ext", str(extension)),
|
||||
("Store", str(store_label)),
|
||||
("Size(Mb)", str(size_str)),
|
||||
]
|
||||
return normalized
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="search-file",
|
||||
summary="Unified search cmdlet for searchable backends (Hydrus, Local, Debrid, LibGen, OpenLibrary, Soulseek).",
|
||||
summary="Unified search cmdlet for storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek).",
|
||||
usage="search-file [query] [-tag TAG] [-size >100MB|<50MB] [-type audio|video|image] [-duration >10:00] [-storage BACKEND] [-provider PROVIDER]",
|
||||
args=[
|
||||
CmdletArg("query", description="Search query string"),
|
||||
@@ -182,11 +184,11 @@ CMDLET = Cmdlet(
|
||||
CmdletArg("type", description="Filter by type: audio, video, image, document"),
|
||||
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
|
||||
CmdletArg("limit", type="integer", description="Limit results (default: 45)"),
|
||||
CmdletArg("storage", description="Search storage backend: hydrus, local, debrid (default: all searchable)"),
|
||||
CmdletArg("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
|
||||
CmdletArg("storage", description="Search storage backend: hydrus, local (default: all searchable storages)"),
|
||||
CmdletArg("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
|
||||
],
|
||||
details=[
|
||||
"Search across multiple providers: File storage (Hydrus, Local, Debrid), Books (LibGen, OpenLibrary), Music (Soulseek)",
|
||||
"Search across storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, Soulseek)",
|
||||
"Use -provider to search a specific source, or -storage to search file backends",
|
||||
"Filter results by: tag, size, type, duration",
|
||||
"Results can be piped to other commands",
|
||||
@@ -216,6 +218,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
storage_backend: Optional[str] = None
|
||||
provider_name: Optional[str] = None
|
||||
limit = 45
|
||||
searched_backends: List[str] = []
|
||||
|
||||
# Simple argument parsing
|
||||
i = 0
|
||||
@@ -249,6 +252,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Debrid is provider-only now
|
||||
if storage_backend and storage_backend.lower() == "debrid":
|
||||
log("Use -provider debrid instead of -storage debrid (debrid is provider-only)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Handle piped input (e.g. from @N selection) if query is empty
|
||||
if not query and result:
|
||||
@@ -351,7 +359,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
# Otherwise search using FileStorage (Hydrus, Local, Debrid backends)
|
||||
# Otherwise search using storage backends (Hydrus, Local)
|
||||
from helper.file_storage import FileStorage
|
||||
storage = FileStorage(config=config or {})
|
||||
|
||||
@@ -364,6 +372,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
log(f"Backend 'hydrus' is not available (Hydrus service not running)", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
searched_backends.append(backend_to_search)
|
||||
if not storage.supports_search(backend_to_search):
|
||||
log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
@@ -379,6 +388,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Skip hydrus if not available
|
||||
if backend_name == "hydrus" and not hydrus_available:
|
||||
continue
|
||||
searched_backends.append(backend_name)
|
||||
try:
|
||||
backend_results = storage[backend_name].search(query, limit=limit - len(all_results))
|
||||
if backend_results:
|
||||
@@ -388,25 +398,65 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except Exception as exc:
|
||||
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
|
||||
results = all_results[:limit]
|
||||
|
||||
# Also query Debrid provider by default (provider-only, but keep legacy coverage when no explicit provider given)
|
||||
if not provider_name and not storage_backend:
|
||||
try:
|
||||
debrid_provider = get_provider("debrid", config)
|
||||
if debrid_provider and debrid_provider.validate():
|
||||
remaining = max(0, limit - len(results)) if isinstance(results, list) else limit
|
||||
if remaining > 0:
|
||||
debrid_results = debrid_provider.search(query, limit=remaining)
|
||||
if debrid_results:
|
||||
if "debrid" not in searched_backends:
|
||||
searched_backends.append("debrid")
|
||||
if results is None:
|
||||
results = []
|
||||
results.extend(debrid_results)
|
||||
except Exception as exc:
|
||||
log(f"Debrid provider search failed: {exc}", file=sys.stderr)
|
||||
|
||||
def _format_storage_label(name: str) -> str:
|
||||
clean = str(name or "").strip()
|
||||
if not clean:
|
||||
return "Unknown"
|
||||
return clean.replace("_", " ").title()
|
||||
|
||||
storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
|
||||
for item in results or []:
|
||||
origin = getattr(item, 'origin', None)
|
||||
if origin is None and isinstance(item, dict):
|
||||
origin = item.get('origin') or item.get('source')
|
||||
if not origin:
|
||||
continue
|
||||
key = str(origin).lower()
|
||||
if key not in storage_counts:
|
||||
storage_counts[key] = 0
|
||||
storage_counts[key] += 1
|
||||
|
||||
if storage_counts or query:
|
||||
display_counts = OrderedDict((_format_storage_label(name), count) for name, count in storage_counts.items())
|
||||
summary_line = table.set_storage_summary(display_counts, query, inline=True)
|
||||
if summary_line:
|
||||
table.title = summary_line
|
||||
|
||||
# Emit results and collect for workers table
|
||||
if results:
|
||||
for item in results:
|
||||
# Add to table
|
||||
table.add_result(item)
|
||||
|
||||
if isinstance(item, dict):
|
||||
normalized = _ensure_storage_columns(item)
|
||||
results_list.append(normalized)
|
||||
ctx.emit(normalized)
|
||||
elif isinstance(item, ResultItem):
|
||||
item_dict = item.to_dict()
|
||||
results_list.append(item_dict)
|
||||
ctx.emit(item_dict)
|
||||
else:
|
||||
item_dict = {"title": str(item)}
|
||||
results_list.append(item_dict)
|
||||
ctx.emit(item_dict)
|
||||
def _as_dict(obj: Any) -> Dict[str, Any]:
|
||||
if isinstance(obj, dict):
|
||||
return dict(obj)
|
||||
if hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict")):
|
||||
return obj.to_dict() # type: ignore[arg-type]
|
||||
return {"title": str(obj)}
|
||||
|
||||
item_dict = _as_dict(item)
|
||||
normalized = _ensure_storage_columns(item_dict)
|
||||
# Add to table using normalized columns to avoid extra fields (e.g., Tags/Name)
|
||||
table.add_result(normalized)
|
||||
|
||||
results_list.append(normalized)
|
||||
ctx.emit(normalized)
|
||||
|
||||
# Set the result table in context for TUI/CLI display
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
|
||||
@@ -632,6 +632,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
debug("MPV is starting up...")
|
||||
return 0
|
||||
else:
|
||||
# Do not auto-launch MPV when no action/inputs were provided; avoid surprise startups
|
||||
no_inputs = not any([
|
||||
result, url_arg, index_arg, clear_mode, play_mode,
|
||||
pause_mode, save_mode, load_mode, current_mode, list_mode
|
||||
])
|
||||
|
||||
if no_inputs:
|
||||
debug("MPV is not running. Skipping auto-launch (no inputs).", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
debug("MPV is not running. Starting new instance...")
|
||||
_start_mpv([], config=config)
|
||||
return 0
|
||||
@@ -716,8 +726,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
is_current = item.get("current", False)
|
||||
title = _extract_title_from_item(item)
|
||||
store = _infer_store_from_playlist_item(item)
|
||||
filename = item.get("filename", "") if isinstance(item, dict) else ""
|
||||
display_loc = _format_playlist_location(filename)
|
||||
|
||||
# Truncate if too long
|
||||
if len(title) > 80:
|
||||
@@ -727,7 +735,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
row.add_column("Current", "*" if is_current else "")
|
||||
row.add_column("Store", store)
|
||||
row.add_column("Title", title)
|
||||
row.add_column("Filename", display_loc)
|
||||
|
||||
table.set_row_selection_args(i, [str(i + 1)])
|
||||
|
||||
|
||||
@@ -381,6 +381,81 @@ class LocalStorageBackend(StorageBackend):
|
||||
"size_bytes": size_bytes,
|
||||
"tags": tags,
|
||||
})
|
||||
if limit is not None and len(results) >= limit:
|
||||
return results
|
||||
|
||||
# Title-tag search: treat freeform terms as title namespace queries (AND across terms)
|
||||
if terms:
|
||||
title_hits: dict[int, dict[str, Any]] = {}
|
||||
for term in terms:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT f.id, f.file_path, f.file_size
|
||||
FROM files f
|
||||
JOIN tags t ON f.id = t.file_id
|
||||
WHERE LOWER(t.tag) LIKE ?
|
||||
ORDER BY f.file_path
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"title:%{term}%", fetch_limit),
|
||||
)
|
||||
for file_id, file_path_str, size_bytes in cursor.fetchall():
|
||||
if not file_path_str:
|
||||
continue
|
||||
entry = title_hits.get(file_id)
|
||||
if entry:
|
||||
entry["count"] += 1
|
||||
if size_bytes is not None:
|
||||
entry["size"] = size_bytes
|
||||
else:
|
||||
title_hits[file_id] = {
|
||||
"path": file_path_str,
|
||||
"size": size_bytes,
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
if title_hits:
|
||||
required = len(terms)
|
||||
for file_id, info in title_hits.items():
|
||||
if info.get("count") != required:
|
||||
continue
|
||||
file_path_str = info.get("path")
|
||||
if not file_path_str or file_path_str in seen_files:
|
||||
continue
|
||||
file_path = Path(file_path_str)
|
||||
if not file_path.exists():
|
||||
continue
|
||||
seen_files.add(file_path_str)
|
||||
|
||||
size_bytes = info.get("size")
|
||||
if size_bytes is None:
|
||||
try:
|
||||
size_bytes = file_path.stat().st_size
|
||||
except OSError:
|
||||
size_bytes = None
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT tag FROM tags WHERE file_id = ?
|
||||
""",
|
||||
(file_id,),
|
||||
)
|
||||
tags = [row[0] for row in cursor.fetchall()]
|
||||
title_tag = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None)
|
||||
|
||||
results.append({
|
||||
"name": file_path.stem,
|
||||
"title": title_tag or file_path.stem,
|
||||
"ext": file_path.suffix.lstrip('.'),
|
||||
"path": str(file_path),
|
||||
"target": str(file_path),
|
||||
"origin": "local",
|
||||
"size": size_bytes,
|
||||
"size_bytes": size_bytes,
|
||||
"tags": tags,
|
||||
})
|
||||
if limit is not None and len(results) >= limit:
|
||||
return results
|
||||
|
||||
# Also search for simple tags (without namespace) containing the query
|
||||
# Only perform tag search if single term, or if we want to support multi-term tag search
|
||||
@@ -697,28 +772,35 @@ class HydrusStorageBackend(StorageBackend):
|
||||
# debug(f"[HydrusBackend.search] Processing file_id={file_id}, tags type={type(tags_set)}")
|
||||
|
||||
if isinstance(tags_set, dict):
|
||||
# debug(f"[HydrusBackend.search] Tags payload keys: {list(tags_set.keys())}")
|
||||
# Collect both storage_tags and display_tags to capture siblings/parents and ensure title: is seen
|
||||
def _collect(tag_list: Any) -> None:
|
||||
nonlocal title, all_tags_str
|
||||
if not isinstance(tag_list, list):
|
||||
return
|
||||
for tag in tag_list:
|
||||
tag_text = str(tag) if tag else ""
|
||||
if not tag_text:
|
||||
continue
|
||||
all_tags.append(tag_text)
|
||||
all_tags_str += " " + tag_text.lower()
|
||||
if tag_text.lower().startswith("title:") and title == f"Hydrus File {file_id}":
|
||||
title = tag_text.split(":", 1)[1].strip()
|
||||
|
||||
for service_name, service_tags in tags_set.items():
|
||||
# debug(f"[HydrusBackend.search] Processing service: {service_name}")
|
||||
if isinstance(service_tags, dict):
|
||||
storage_tags = service_tags.get("storage_tags", {})
|
||||
if isinstance(storage_tags, dict):
|
||||
for tag_type, tag_list in storage_tags.items():
|
||||
# debug(f"[HydrusBackend.search] Tag type: {tag_type}, count: {len(tag_list) if isinstance(tag_list, list) else 0}")
|
||||
if isinstance(tag_list, list):
|
||||
for tag in tag_list:
|
||||
tag_text = str(tag) if tag else ""
|
||||
if tag_text:
|
||||
# debug(f"[HydrusBackend.search] Tag: {tag_text}")
|
||||
all_tags.append(tag_text)
|
||||
all_tags_str += " " + tag_text.lower()
|
||||
# Extract title: namespace
|
||||
if tag_text.startswith("title:"):
|
||||
title = tag_text[6:].strip() # Remove "title:" prefix
|
||||
# debug(f"[HydrusBackend.search] ✓ Extracted title: {title}")
|
||||
break
|
||||
if title != f"Hydrus File {file_id}":
|
||||
break
|
||||
if not isinstance(service_tags, dict):
|
||||
continue
|
||||
|
||||
storage_tags = service_tags.get("storage_tags", {})
|
||||
if isinstance(storage_tags, dict):
|
||||
for tag_list in storage_tags.values():
|
||||
_collect(tag_list)
|
||||
|
||||
display_tags = service_tags.get("display_tags", [])
|
||||
_collect(display_tags)
|
||||
|
||||
# Also consider top-level flattened tags payload if provided (Hydrus API sometimes includes it)
|
||||
top_level_tags = meta.get("tags_flat", []) or meta.get("tags", [])
|
||||
_collect(top_level_tags)
|
||||
|
||||
# Resolve extension from MIME type
|
||||
mime_type = meta.get("mime")
|
||||
@@ -796,202 +878,6 @@ class HydrusStorageBackend(StorageBackend):
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
class DebridStorageBackend(StorageBackend):
|
||||
"""File storage backend for Debrid services (AllDebrid, RealDebrid, etc.)."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None) -> None:
|
||||
"""Initialize Debrid storage backend.
|
||||
|
||||
Args:
|
||||
api_key: API key for Debrid service (e.g., from config["Debrid"]["All-debrid"])
|
||||
"""
|
||||
self._api_key = api_key
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "debrid"
|
||||
|
||||
def upload(self, file_path: Path, **kwargs: Any) -> str:
|
||||
"""Upload file to Debrid service.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to upload
|
||||
**kwargs: Debrid-specific options
|
||||
|
||||
Returns:
|
||||
Debrid link/URL
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Debrid upload not yet implemented
|
||||
"""
|
||||
raise NotImplementedError("Debrid upload not yet implemented")
|
||||
|
||||
def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
|
||||
"""Search Debrid for files matching query.
|
||||
|
||||
Searches through available magnets in AllDebrid storage and returns
|
||||
matching results with download links.
|
||||
|
||||
Args:
|
||||
query: Search query string (filename or magnet name pattern)
|
||||
limit: Maximum number of results to return (default: 50)
|
||||
api_key: Optional override for API key (uses default if not provided)
|
||||
|
||||
Returns:
|
||||
List of dicts with keys:
|
||||
- 'name': File/magnet name
|
||||
- 'title': Same as name (for compatibility)
|
||||
- 'url': AllDebrid download link
|
||||
- 'size': File size in bytes
|
||||
- 'magnet_id': AllDebrid magnet ID
|
||||
- 'origin': 'debrid'
|
||||
- 'annotations': Status and seeders info
|
||||
|
||||
Example:
|
||||
results = storage["debrid"].search("movie.mkv")
|
||||
for result in results:
|
||||
print(f"{result['name']} - {result['size']} bytes")
|
||||
"""
|
||||
api_key = kwargs.get("api_key") or self._api_key
|
||||
if not api_key:
|
||||
raise ValueError("'api_key' parameter required for Debrid search (not configured)")
|
||||
|
||||
limit = kwargs.get("limit", 50)
|
||||
|
||||
try:
|
||||
from helper.alldebrid import AllDebridClient
|
||||
|
||||
debug(f"Searching AllDebrid for: {query}")
|
||||
|
||||
client = AllDebridClient(api_key=api_key)
|
||||
|
||||
# STEP 1: Get magnet status list
|
||||
try:
|
||||
response = client._request('magnet/status')
|
||||
magnets_data = response.get('data', {})
|
||||
magnets = magnets_data.get('magnets', [])
|
||||
if not isinstance(magnets, list):
|
||||
magnets = [magnets] if magnets else []
|
||||
debug(f"[debrid_search] Got {len(magnets)} total magnets")
|
||||
except Exception as e:
|
||||
log(f"⚠ Failed to get magnets list: {e}", file=sys.stderr)
|
||||
magnets = []
|
||||
|
||||
# Filter by query for relevant magnets
|
||||
query_lower = query.lower()
|
||||
matching_magnet_ids = []
|
||||
magnet_info_map = {} # Store status info for later
|
||||
|
||||
# "*" means "match all" - include all magnets
|
||||
match_all = query_lower == "*"
|
||||
|
||||
# Split query into terms for AND logic
|
||||
terms = [t.strip() for t in query_lower.replace(',', ' ').split() if t.strip()]
|
||||
if not terms:
|
||||
terms = [query_lower]
|
||||
|
||||
for magnet in magnets:
|
||||
filename = magnet.get('filename', '').lower()
|
||||
status_code = magnet.get('statusCode', 0)
|
||||
magnet_id = magnet.get('id')
|
||||
|
||||
# Only include ready or nearly-ready magnets (skip error states 5+)
|
||||
if status_code not in [0, 1, 2, 3, 4]:
|
||||
continue
|
||||
|
||||
# Match query against filename (or match all if query is "*")
|
||||
if not match_all:
|
||||
if not all(term in filename for term in terms):
|
||||
continue
|
||||
|
||||
matching_magnet_ids.append(magnet_id)
|
||||
magnet_info_map[magnet_id] = magnet
|
||||
debug(f"[debrid_search] ✓ Matched magnet {magnet_id}: {filename}")
|
||||
|
||||
debug(f"[debrid_search] Found {len(matching_magnet_ids)} matching magnets")
|
||||
|
||||
results = []
|
||||
|
||||
# Return one result per magnet (not per file)
|
||||
# This keeps search results clean and allows user to download entire magnet at once
|
||||
for magnet_id in matching_magnet_ids:
|
||||
magnet_status = magnet_info_map.get(magnet_id, {})
|
||||
filename = magnet_status.get('filename', 'Unknown')
|
||||
status = magnet_status.get('status', 'Unknown')
|
||||
status_code = magnet_status.get('statusCode', 0)
|
||||
size = magnet_status.get('size', 0)
|
||||
seeders = magnet_status.get('seeders', 0)
|
||||
|
||||
# Format size nicely
|
||||
size_label = f"{size / (1024**3):.2f}GB" if size > 0 else "Unknown"
|
||||
|
||||
# Create one result per magnet with aggregated info
|
||||
results.append({
|
||||
'name': filename,
|
||||
'title': filename,
|
||||
'url': '', # No direct file link for the magnet itself
|
||||
'size': size,
|
||||
'size_bytes': size,
|
||||
'magnet_id': magnet_id,
|
||||
'origin': 'debrid',
|
||||
'annotations': [
|
||||
status,
|
||||
f"{seeders} seeders",
|
||||
size_label,
|
||||
],
|
||||
'target': '', # Magnet ID is stored, user can then download it
|
||||
})
|
||||
|
||||
debug(f"Found {len(results)} result(s) on AllDebrid")
|
||||
return results[:limit]
|
||||
|
||||
except Exception as exc:
|
||||
log(f"❌ Debrid search failed: {exc}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
def _flatten_file_tree(self, files: list[Any], prefix: str = '') -> list[Dict[str, Any]]:
|
||||
"""Flatten AllDebrid's nested file tree structure.
|
||||
|
||||
AllDebrid returns files in a tree structure with folders ('e' key).
|
||||
This flattens it to a list of individual files.
|
||||
|
||||
Args:
|
||||
files: AllDebrid file tree structure
|
||||
prefix: Current path prefix (used recursively)
|
||||
|
||||
Returns:
|
||||
List of flattened file entries with 'name', 'size', 'link' keys
|
||||
"""
|
||||
result = []
|
||||
|
||||
if not isinstance(files, list):
|
||||
return result
|
||||
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
name = item.get('n', '')
|
||||
|
||||
# Check if it's a folder (has 'e' key with entries)
|
||||
if 'e' in item:
|
||||
# Recursively flatten subfolder
|
||||
subfolder_path = f"{prefix}/{name}" if prefix else name
|
||||
subitems = item.get('e', [])
|
||||
result.extend(self._flatten_file_tree(subitems, subfolder_path))
|
||||
else:
|
||||
# It's a file - add it to results
|
||||
file_path = f"{prefix}/{name}" if prefix else name
|
||||
result.append({
|
||||
'name': file_path,
|
||||
'size': item.get('s', 0),
|
||||
'link': item.get('l', ''),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MatrixStorageBackend(StorageBackend):
|
||||
"""File storage backend for Matrix (Element) chat rooms."""
|
||||
|
||||
@@ -1344,7 +1230,6 @@ class FileStorage:
|
||||
# Search with searchable backends (uses configured locations)
|
||||
results = storage["hydrus"].search("music")
|
||||
results = storage["local"].search("song") # Uses config["Local"]["path"]
|
||||
results = storage["debrid"].search("movie")
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
@@ -1356,13 +1241,11 @@ class FileStorage:
|
||||
config = config or {}
|
||||
|
||||
# Extract backend-specific settings from config
|
||||
from config import get_local_storage_path, get_debrid_api_key
|
||||
from config import get_local_storage_path
|
||||
|
||||
local_path = get_local_storage_path(config)
|
||||
local_path_str = str(local_path) if local_path else None
|
||||
|
||||
debrid_api_key = get_debrid_api_key(config)
|
||||
|
||||
self._backends: Dict[str, StorageBackend] = {}
|
||||
|
||||
# Always include local backend (even if no default path configured)
|
||||
@@ -1372,10 +1255,6 @@ class FileStorage:
|
||||
# Include Hydrus backend (configuration optional)
|
||||
self._backends["hydrus"] = HydrusStorageBackend(config=config)
|
||||
|
||||
# Include Debrid backend (API key optional - will raise on use if not provided)
|
||||
if debrid_api_key:
|
||||
self._backends["debrid"] = DebridStorageBackend(api_key=debrid_api_key)
|
||||
|
||||
# Include Matrix backend
|
||||
self._backends["matrix"] = MatrixStorageBackend()
|
||||
|
||||
|
||||
@@ -71,10 +71,208 @@ class ITunesProvider(MetadataProvider):
|
||||
return items
|
||||
|
||||
|
||||
class OpenLibraryMetadataProvider(MetadataProvider):
|
||||
"""Metadata provider for OpenLibrary book metadata."""
|
||||
|
||||
@property
|
||||
def name(self) -> str: # type: ignore[override]
|
||||
return "openlibrary"
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
query_clean = (query or "").strip()
|
||||
if not query_clean:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Prefer ISBN-specific search when the query looks like one
|
||||
if query_clean.replace("-", "").isdigit() and len(query_clean.replace("-", "")) in (10, 13):
|
||||
q = f"isbn:{query_clean.replace('-', '')}"
|
||||
else:
|
||||
q = query_clean
|
||||
|
||||
resp = requests.get(
|
||||
"https://openlibrary.org/search.json",
|
||||
params={"q": q, "limit": limit},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
log(f"OpenLibrary search failed: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for doc in data.get("docs", [])[:limit]:
|
||||
authors = doc.get("author_name") or []
|
||||
publisher = ""
|
||||
publishers = doc.get("publisher") or []
|
||||
if isinstance(publishers, list) and publishers:
|
||||
publisher = publishers[0]
|
||||
|
||||
# Prefer 13-digit ISBN when available, otherwise 10-digit
|
||||
isbn_list = doc.get("isbn") or []
|
||||
isbn_13 = next((i for i in isbn_list if len(str(i)) == 13), None)
|
||||
isbn_10 = next((i for i in isbn_list if len(str(i)) == 10), None)
|
||||
|
||||
# Derive OLID from key
|
||||
olid = ""
|
||||
key = doc.get("key", "")
|
||||
if isinstance(key, str) and key:
|
||||
olid = key.split("/")[-1]
|
||||
|
||||
items.append({
|
||||
"title": doc.get("title") or "",
|
||||
"artist": ", ".join(authors) if authors else "",
|
||||
"album": publisher,
|
||||
"year": str(doc.get("first_publish_year") or ""),
|
||||
"provider": self.name,
|
||||
"authors": authors,
|
||||
"publisher": publisher,
|
||||
"identifiers": {
|
||||
"isbn_13": isbn_13,
|
||||
"isbn_10": isbn_10,
|
||||
"openlibrary": olid,
|
||||
"oclc": (doc.get("oclc_numbers") or [None])[0],
|
||||
"lccn": (doc.get("lccn") or [None])[0],
|
||||
},
|
||||
"description": None,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
def to_tags(self, item: Dict[str, Any]) -> List[str]:
|
||||
tags: List[str] = []
|
||||
title = item.get("title")
|
||||
authors = item.get("authors") or []
|
||||
publisher = item.get("publisher")
|
||||
year = item.get("year")
|
||||
description = item.get("description") or ""
|
||||
|
||||
if title:
|
||||
tags.append(f"title:{title}")
|
||||
for author in authors:
|
||||
if author:
|
||||
tags.append(f"author:{author}")
|
||||
if publisher:
|
||||
tags.append(f"publisher:{publisher}")
|
||||
if year:
|
||||
tags.append(f"year:{year}")
|
||||
if description:
|
||||
tags.append(f"description:{description[:200]}")
|
||||
|
||||
identifiers = item.get("identifiers") or {}
|
||||
for key, value in identifiers.items():
|
||||
if value:
|
||||
tags.append(f"{key}:{value}")
|
||||
|
||||
tags.append(f"source:{self.name}")
|
||||
return tags
|
||||
|
||||
|
||||
class GoogleBooksMetadataProvider(MetadataProvider):
|
||||
"""Metadata provider for Google Books volumes API."""
|
||||
|
||||
@property
|
||||
def name(self) -> str: # type: ignore[override]
|
||||
return "googlebooks"
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
query_clean = (query or "").strip()
|
||||
if not query_clean:
|
||||
return []
|
||||
|
||||
# Prefer ISBN queries when possible
|
||||
if query_clean.replace("-", "").isdigit() and len(query_clean.replace("-", "")) in (10, 13):
|
||||
q = f"isbn:{query_clean.replace('-', '')}"
|
||||
else:
|
||||
q = query_clean
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://www.googleapis.com/books/v1/volumes",
|
||||
params={"q": q, "maxResults": limit},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
log(f"Google Books search failed: {exc}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for volume in payload.get("items", [])[:limit]:
|
||||
info = volume.get("volumeInfo") or {}
|
||||
authors = info.get("authors") or []
|
||||
publisher = info.get("publisher", "")
|
||||
published_date = info.get("publishedDate", "")
|
||||
year = str(published_date)[:4] if published_date else ""
|
||||
|
||||
identifiers_raw = info.get("industryIdentifiers") or []
|
||||
identifiers: Dict[str, Optional[str]] = {"googlebooks": volume.get("id")}
|
||||
for ident in identifiers_raw:
|
||||
if not isinstance(ident, dict):
|
||||
continue
|
||||
ident_type = ident.get("type", "").lower()
|
||||
ident_value = ident.get("identifier")
|
||||
if not ident_value:
|
||||
continue
|
||||
if ident_type == "isbn_13":
|
||||
identifiers.setdefault("isbn_13", ident_value)
|
||||
elif ident_type == "isbn_10":
|
||||
identifiers.setdefault("isbn_10", ident_value)
|
||||
else:
|
||||
identifiers.setdefault(ident_type, ident_value)
|
||||
|
||||
items.append({
|
||||
"title": info.get("title") or "",
|
||||
"artist": ", ".join(authors) if authors else "",
|
||||
"album": publisher,
|
||||
"year": year,
|
||||
"provider": self.name,
|
||||
"authors": authors,
|
||||
"publisher": publisher,
|
||||
"identifiers": identifiers,
|
||||
"description": info.get("description", ""),
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
def to_tags(self, item: Dict[str, Any]) -> List[str]:
|
||||
tags: List[str] = []
|
||||
title = item.get("title")
|
||||
authors = item.get("authors") or []
|
||||
publisher = item.get("publisher")
|
||||
year = item.get("year")
|
||||
description = item.get("description") or ""
|
||||
|
||||
if title:
|
||||
tags.append(f"title:{title}")
|
||||
for author in authors:
|
||||
if author:
|
||||
tags.append(f"author:{author}")
|
||||
if publisher:
|
||||
tags.append(f"publisher:{publisher}")
|
||||
if year:
|
||||
tags.append(f"year:{year}")
|
||||
if description:
|
||||
tags.append(f"description:{description[:200]}")
|
||||
|
||||
identifiers = item.get("identifiers") or {}
|
||||
for key, value in identifiers.items():
|
||||
if value:
|
||||
tags.append(f"{key}:{value}")
|
||||
|
||||
tags.append(f"source:{self.name}")
|
||||
return tags
|
||||
|
||||
|
||||
# Registry ---------------------------------------------------------------
|
||||
|
||||
_METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = {
|
||||
"itunes": ITunesProvider,
|
||||
"openlibrary": OpenLibraryMetadataProvider,
|
||||
"googlebooks": GoogleBooksMetadataProvider,
|
||||
"google": GoogleBooksMetadataProvider,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -293,13 +293,7 @@ class LocalStorageProvider(SearchProvider):
|
||||
class LibGenProvider(SearchProvider):
|
||||
"""Search provider for Library Genesis books."""
|
||||
|
||||
# Define fields to display (note: LibGen doesn't have API field mapping like OpenLibrary)
|
||||
# These are extracted from the book dict directly
|
||||
RESULT_FIELDS = [
|
||||
("title", "Title", None),
|
||||
("author", "Author(s)", None),
|
||||
("year", "Year", None),
|
||||
]
|
||||
RESULT_FIELDS: List[Tuple[str, str, Optional[Any]]] = [] # columns built manually
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
@@ -363,15 +357,22 @@ class LibGenProvider(SearchProvider):
|
||||
|
||||
search_results = []
|
||||
for idx, book in enumerate(books, 1):
|
||||
# Build columns dynamically from RESULT_FIELDS
|
||||
columns = self.build_columns_from_doc(book, idx)
|
||||
|
||||
title = book.get("title", "Unknown")
|
||||
author = book.get("author", "Unknown")
|
||||
year = book.get("year", "Unknown")
|
||||
pages = book.get("pages") or book.get("pages_str") or ""
|
||||
extension = book.get("extension", "") or book.get("ext", "")
|
||||
filesize = book.get("filesize_str", "Unknown")
|
||||
isbn = book.get("isbn", "")
|
||||
mirror_url = book.get("mirror_url", "")
|
||||
|
||||
# Columns: Title, Author, Pages, Ext
|
||||
columns = [
|
||||
("Title", title),
|
||||
("Author", author),
|
||||
("Pages", str(pages)),
|
||||
("Ext", str(extension)),
|
||||
]
|
||||
|
||||
# Build detail with author and year
|
||||
detail = f"By: {author}"
|
||||
@@ -1077,12 +1078,7 @@ class OpenLibraryProvider(SearchProvider):
|
||||
"""Search provider for OpenLibrary."""
|
||||
|
||||
# Define fields to request from API and how to display them
|
||||
RESULT_FIELDS = [
|
||||
("title", "Title", None),
|
||||
("author_name", "Author", lambda x: ", ".join(x) if isinstance(x, list) else x),
|
||||
("first_publish_year", "Year", None),
|
||||
("status", "Status", None),
|
||||
]
|
||||
RESULT_FIELDS: List[Tuple[str, str, Optional[Any]]] = [] # columns built manually
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None):
|
||||
super().__init__(config)
|
||||
@@ -1146,10 +1142,25 @@ class OpenLibraryProvider(SearchProvider):
|
||||
return []
|
||||
|
||||
# Default to title/general search
|
||||
requested_fields = [
|
||||
"title",
|
||||
"author_name",
|
||||
"first_publish_year",
|
||||
"number_of_pages_median",
|
||||
"isbn",
|
||||
"oclc_numbers",
|
||||
"lccn",
|
||||
"language",
|
||||
"key",
|
||||
"edition_key",
|
||||
"ebook_access",
|
||||
"ia",
|
||||
"has_fulltext",
|
||||
]
|
||||
params = {
|
||||
"q": query_clean,
|
||||
"limit": limit,
|
||||
"fields": f"{self.get_api_fields_string()},isbn,oclc_numbers,lccn,number_of_pages_median,language,key,ebook_access,ia,has_fulltext",
|
||||
"fields": ",".join(requested_fields),
|
||||
}
|
||||
|
||||
response = requests.get(search_url, params=params, timeout=9)
|
||||
@@ -1158,16 +1169,18 @@ class OpenLibraryProvider(SearchProvider):
|
||||
|
||||
search_results = []
|
||||
for idx, doc in enumerate(data.get("docs", []), 1):
|
||||
# Extract OLID first (needed for metadata)
|
||||
olid = doc.get("key", "").split("/")[-1]
|
||||
# Prefer edition_key (books/OLxxxM). Fallback to work key.
|
||||
edition_keys = doc.get("edition_key") or []
|
||||
olid = ""
|
||||
if isinstance(edition_keys, list) and edition_keys:
|
||||
olid = str(edition_keys[0]).strip()
|
||||
if not olid:
|
||||
olid = doc.get("key", "").split("/")[-1]
|
||||
|
||||
# Determine status/availability
|
||||
status, archive_id = self._derive_status(doc)
|
||||
doc["status"] = status
|
||||
|
||||
# Build columns dynamically from RESULT_FIELDS (now includes status)
|
||||
columns = self.build_columns_from_doc(doc, idx)
|
||||
|
||||
# Extract additional metadata
|
||||
title = doc.get("title", "Unknown")
|
||||
authors = doc.get("author_name", ["Unknown"])
|
||||
@@ -1183,6 +1196,13 @@ class OpenLibraryProvider(SearchProvider):
|
||||
language = languages[0] if languages else ""
|
||||
|
||||
author_str = ", ".join(authors) if authors else "Unknown"
|
||||
|
||||
# Columns: Title, Author, Pages
|
||||
columns = [
|
||||
("Title", title),
|
||||
("Author", author_str),
|
||||
("Pages", str(pages or "")),
|
||||
]
|
||||
|
||||
# Build detail with author and year
|
||||
detail = f"By: {author_str}"
|
||||
|
||||
59
pipeline.py
59
pipeline.py
@@ -52,9 +52,11 @@ _PIPELINE_LAST_ITEMS: List[Any] = []
|
||||
# Store the last result table for @ selection syntax (e.g., @2, @2-5, @{1,3,5})
|
||||
_LAST_RESULT_TABLE: Optional[Any] = None
|
||||
_LAST_RESULT_ITEMS: List[Any] = []
|
||||
# Subject for the current result table (e.g., the file whose tags/URLs are displayed)
|
||||
_LAST_RESULT_SUBJECT: Optional[Any] = None
|
||||
|
||||
# History of result tables for @.. navigation (LIFO stack, max 20 tables)
|
||||
_RESULT_TABLE_HISTORY: List[tuple[Optional[Any], List[Any]]] = []
|
||||
_RESULT_TABLE_HISTORY: List[tuple[Optional[Any], List[Any], Optional[Any]]] = []
|
||||
_MAX_RESULT_TABLE_HISTORY = 20
|
||||
|
||||
# Current stage table for @N expansion (separate from history)
|
||||
@@ -70,6 +72,8 @@ _DISPLAY_ITEMS: List[Any] = []
|
||||
# Table for display-only commands (overlay)
|
||||
# Used when a command wants to show a specific table formatting but not affect history
|
||||
_DISPLAY_TABLE: Optional[Any] = None
|
||||
# Subject for overlay/display-only tables (takes precedence over _LAST_RESULT_SUBJECT)
|
||||
_DISPLAY_SUBJECT: Optional[Any] = None
|
||||
|
||||
# Track the indices the user selected via @ syntax for the current invocation
|
||||
_PIPELINE_LAST_SELECTION: List[int] = []
|
||||
@@ -262,7 +266,7 @@ def reset() -> None:
|
||||
"""Reset all pipeline state. Called between pipeline executions."""
|
||||
global _PIPE_EMITS, _PIPE_ACTIVE, _PIPE_IS_LAST, _PIPELINE_VALUES
|
||||
global _LAST_PIPELINE_CAPTURE, _PIPELINE_REFRESHED, _PIPELINE_LAST_ITEMS
|
||||
global _PIPELINE_COMMAND_TEXT
|
||||
global _PIPELINE_COMMAND_TEXT, _LAST_RESULT_SUBJECT, _DISPLAY_SUBJECT
|
||||
|
||||
_PIPE_EMITS = []
|
||||
_PIPE_ACTIVE = False
|
||||
@@ -272,6 +276,8 @@ def reset() -> None:
|
||||
_PIPELINE_LAST_ITEMS = []
|
||||
_PIPELINE_VALUES = {}
|
||||
_PIPELINE_COMMAND_TEXT = ""
|
||||
_LAST_RESULT_SUBJECT = None
|
||||
_DISPLAY_SUBJECT = None
|
||||
|
||||
|
||||
def get_emitted_items() -> List[Any]:
|
||||
@@ -419,7 +425,7 @@ def trigger_ui_library_refresh(library_filter: str = 'local') -> None:
|
||||
print(f"[trigger_ui_library_refresh] Error calling refresh callback: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def set_last_result_table(result_table: Optional[Any], items: Optional[List[Any]] = None) -> None:
|
||||
def set_last_result_table(result_table: Optional[Any], items: Optional[List[Any]] = None, subject: Optional[Any] = None) -> None:
|
||||
"""Store the last result table and items for @ selection syntax.
|
||||
|
||||
This should be called after displaying a result table, so users can reference
|
||||
@@ -433,11 +439,12 @@ def set_last_result_table(result_table: Optional[Any], items: Optional[List[Any]
|
||||
result_table: The ResultTable object that was displayed (or None)
|
||||
items: List of items that populated the table (optional)
|
||||
"""
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _RESULT_TABLE_HISTORY, _DISPLAY_ITEMS, _DISPLAY_TABLE
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
|
||||
global _RESULT_TABLE_HISTORY, _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
|
||||
|
||||
# Push current table to history before replacing
|
||||
if _LAST_RESULT_TABLE is not None:
|
||||
_RESULT_TABLE_HISTORY.append((_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS.copy()))
|
||||
_RESULT_TABLE_HISTORY.append((_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS.copy(), _LAST_RESULT_SUBJECT))
|
||||
# Keep history size limited
|
||||
if len(_RESULT_TABLE_HISTORY) > _MAX_RESULT_TABLE_HISTORY:
|
||||
_RESULT_TABLE_HISTORY.pop(0)
|
||||
@@ -445,11 +452,13 @@ def set_last_result_table(result_table: Optional[Any], items: Optional[List[Any]
|
||||
# Set new current table and clear any display items/table
|
||||
_DISPLAY_ITEMS = []
|
||||
_DISPLAY_TABLE = None
|
||||
_DISPLAY_SUBJECT = None
|
||||
_LAST_RESULT_TABLE = result_table
|
||||
_LAST_RESULT_ITEMS = items or []
|
||||
_LAST_RESULT_SUBJECT = subject
|
||||
|
||||
|
||||
def set_last_result_table_overlay(result_table: Optional[Any], items: Optional[List[Any]] = None) -> None:
|
||||
def set_last_result_table_overlay(result_table: Optional[Any], items: Optional[List[Any]] = None, subject: Optional[Any] = None) -> None:
|
||||
"""Set a result table as an overlay (display only, no history).
|
||||
|
||||
Used for commands like get-tag that want to show a formatted table but
|
||||
@@ -459,13 +468,14 @@ def set_last_result_table_overlay(result_table: Optional[Any], items: Optional[L
|
||||
result_table: The ResultTable object to display
|
||||
items: List of items for @N selection
|
||||
"""
|
||||
global _DISPLAY_ITEMS, _DISPLAY_TABLE
|
||||
global _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
|
||||
|
||||
_DISPLAY_TABLE = result_table
|
||||
_DISPLAY_ITEMS = items or []
|
||||
_DISPLAY_SUBJECT = subject
|
||||
|
||||
|
||||
def set_last_result_table_preserve_history(result_table: Optional[Any], items: Optional[List[Any]] = None) -> None:
|
||||
def set_last_result_table_preserve_history(result_table: Optional[Any], items: Optional[List[Any]] = None, subject: Optional[Any] = None) -> None:
|
||||
"""Update the last result table WITHOUT adding to history.
|
||||
|
||||
Used for action commands (delete-tag, add-tag, etc.) that modify data but shouldn't
|
||||
@@ -475,11 +485,12 @@ def set_last_result_table_preserve_history(result_table: Optional[Any], items: O
|
||||
result_table: The ResultTable object that was displayed (or None)
|
||||
items: List of items that populated the table (optional)
|
||||
"""
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
|
||||
|
||||
# Update current table WITHOUT pushing to history
|
||||
_LAST_RESULT_TABLE = result_table
|
||||
_LAST_RESULT_ITEMS = items or []
|
||||
_LAST_RESULT_SUBJECT = subject
|
||||
|
||||
|
||||
def set_last_result_items_only(items: Optional[List[Any]]) -> None:
|
||||
@@ -494,13 +505,14 @@ def set_last_result_items_only(items: Optional[List[Any]]) -> None:
|
||||
Args:
|
||||
items: List of items to select from
|
||||
"""
|
||||
global _DISPLAY_ITEMS, _DISPLAY_TABLE
|
||||
global _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
|
||||
|
||||
# Store items for immediate @N selection, but DON'T modify _LAST_RESULT_ITEMS
|
||||
# This ensures history contains original search data, not display transformations
|
||||
_DISPLAY_ITEMS = items or []
|
||||
# Clear display table since we're setting items only (CLI will generate table if needed)
|
||||
_DISPLAY_TABLE = None
|
||||
_DISPLAY_SUBJECT = None
|
||||
|
||||
|
||||
def restore_previous_result_table() -> bool:
|
||||
@@ -509,22 +521,32 @@ def restore_previous_result_table() -> bool:
|
||||
Returns:
|
||||
True if a previous table was restored, False if history is empty
|
||||
"""
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _RESULT_TABLE_HISTORY, _DISPLAY_ITEMS, _DISPLAY_TABLE
|
||||
global _LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT
|
||||
global _RESULT_TABLE_HISTORY, _DISPLAY_ITEMS, _DISPLAY_TABLE, _DISPLAY_SUBJECT
|
||||
|
||||
# If we have an active overlay (display items/table), clear it to "go back" to the underlying table
|
||||
if _DISPLAY_ITEMS or _DISPLAY_TABLE:
|
||||
if _DISPLAY_ITEMS or _DISPLAY_TABLE or _DISPLAY_SUBJECT is not None:
|
||||
_DISPLAY_ITEMS = []
|
||||
_DISPLAY_TABLE = None
|
||||
_DISPLAY_SUBJECT = None
|
||||
return True
|
||||
|
||||
if not _RESULT_TABLE_HISTORY:
|
||||
return False
|
||||
|
||||
# Pop from history and restore
|
||||
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS = _RESULT_TABLE_HISTORY.pop()
|
||||
prev = _RESULT_TABLE_HISTORY.pop()
|
||||
if isinstance(prev, tuple) and len(prev) >= 3:
|
||||
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = prev[0], prev[1], prev[2]
|
||||
elif isinstance(prev, tuple) and len(prev) == 2:
|
||||
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS = prev
|
||||
_LAST_RESULT_SUBJECT = None
|
||||
else:
|
||||
_LAST_RESULT_TABLE, _LAST_RESULT_ITEMS, _LAST_RESULT_SUBJECT = None, [], None
|
||||
# Clear display items so get_last_result_items() falls back to restored items
|
||||
_DISPLAY_ITEMS = []
|
||||
_DISPLAY_TABLE = None
|
||||
_DISPLAY_SUBJECT = None
|
||||
return True
|
||||
|
||||
|
||||
@@ -537,6 +559,17 @@ def get_display_table() -> Optional[Any]:
|
||||
return _DISPLAY_TABLE
|
||||
|
||||
|
||||
def get_last_result_subject() -> Optional[Any]:
|
||||
"""Get the subject associated with the current result table or overlay.
|
||||
|
||||
Overlay subject (from display-only tables) takes precedence; otherwise returns
|
||||
the subject stored with the last result table.
|
||||
"""
|
||||
if _DISPLAY_SUBJECT is not None:
|
||||
return _DISPLAY_SUBJECT
|
||||
return _LAST_RESULT_SUBJECT
|
||||
|
||||
|
||||
def get_last_result_table() -> Optional[Any]:
|
||||
"""Get the current last result table.
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ class ResultTable:
|
||||
"""Command that generated this table (e.g., 'download-data URL')"""
|
||||
self.source_args: List[str] = []
|
||||
"""Base arguments for the source command"""
|
||||
self.header_lines: List[str] = []
|
||||
"""Optional metadata lines rendered under the title"""
|
||||
|
||||
def add_row(self) -> ResultRow:
|
||||
"""Add a new row to the table and return it for configuration."""
|
||||
@@ -211,6 +213,34 @@ class ResultTable:
|
||||
"""
|
||||
if 0 <= row_index < len(self.rows):
|
||||
self.rows[row_index].selection_args = selection_args
|
||||
|
||||
def set_header_lines(self, lines: List[str]) -> "ResultTable":
|
||||
"""Attach metadata lines that render beneath the title."""
|
||||
self.header_lines = [line for line in lines if line]
|
||||
return self
|
||||
|
||||
def set_header_line(self, line: str) -> "ResultTable":
|
||||
"""Attach a single metadata line beneath the title."""
|
||||
return self.set_header_lines([line] if line else [])
|
||||
|
||||
def set_storage_summary(self, storage_counts: Dict[str, int], filter_text: Optional[str] = None, inline: bool = False) -> str:
|
||||
"""Render a storage count summary (e.g., "Hydrus:0 Local:1 | filter: \"q\"").
|
||||
|
||||
Returns the summary string so callers can place it inline with the title if desired.
|
||||
"""
|
||||
summary_parts: List[str] = []
|
||||
|
||||
if storage_counts:
|
||||
summary_parts.append(" ".join(f"{name}:{count}" for name, count in storage_counts.items()))
|
||||
|
||||
if filter_text:
|
||||
safe_filter = filter_text.replace("\"", "\\\"")
|
||||
summary_parts.append(f'filter: "{safe_filter}"')
|
||||
|
||||
summary = " | ".join(summary_parts)
|
||||
if not inline:
|
||||
self.set_header_line(summary)
|
||||
return summary
|
||||
|
||||
def add_result(self, result: Any) -> "ResultTable":
|
||||
"""Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row.
|
||||
@@ -249,7 +279,14 @@ class ResultTable:
|
||||
|
||||
def _add_search_result(self, row: ResultRow, result: Any) -> None:
|
||||
"""Extract and add SearchResult fields to row."""
|
||||
# Core fields
|
||||
# If provider supplied explicit columns, render those and skip legacy defaults
|
||||
cols = getattr(result, "columns", None)
|
||||
if cols:
|
||||
for name, value in cols:
|
||||
row.add_column(name, value)
|
||||
return
|
||||
|
||||
# Core fields (legacy fallback)
|
||||
title = getattr(result, 'title', '')
|
||||
origin = getattr(result, 'origin', '').lower()
|
||||
|
||||
@@ -597,6 +634,9 @@ class ResultTable:
|
||||
lines.append("=" * self.title_width)
|
||||
lines.append(self.title.center(self.title_width))
|
||||
lines.append("=" * self.title_width)
|
||||
|
||||
if self.header_lines:
|
||||
lines.extend(self.header_lines)
|
||||
|
||||
# Add header with # column
|
||||
header_parts = ["#".ljust(num_width)]
|
||||
|
||||
Reference in New Issue
Block a user