dfd
This commit is contained in:
@@ -1128,6 +1128,47 @@ def merge_sequences(*sources: Optional[Iterable[Any]], case_sensitive: bool = Tr
|
||||
return merged
|
||||
|
||||
|
||||
def collapse_namespace_tags(tags: Optional[Iterable[Any]], namespace: str, prefer: str = "last") -> list[str]:
|
||||
"""Reduce tags so only one entry for a given namespace remains.
|
||||
|
||||
Keeps either the first or last occurrence (default last) while preserving overall order
|
||||
for non-matching tags. Useful for ensuring a single title: tag.
|
||||
"""
|
||||
if not tags:
|
||||
return []
|
||||
ns = str(namespace or "").strip().lower()
|
||||
if not ns:
|
||||
return list(tags) if isinstance(tags, list) else list(tags)
|
||||
|
||||
prefer_last = str(prefer or "last").lower() != "first"
|
||||
ns_prefix = ns + ":"
|
||||
|
||||
items = list(tags)
|
||||
if prefer_last:
|
||||
kept: list[str] = []
|
||||
seen_ns = False
|
||||
for tag in reversed(items):
|
||||
text = str(tag)
|
||||
if text.lower().startswith(ns_prefix):
|
||||
if seen_ns:
|
||||
continue
|
||||
seen_ns = True
|
||||
kept.append(text)
|
||||
kept.reverse()
|
||||
return kept
|
||||
else:
|
||||
kept_ns = False
|
||||
result: list[str] = []
|
||||
for tag in items:
|
||||
text = str(tag)
|
||||
if text.lower().startswith(ns_prefix):
|
||||
if kept_ns:
|
||||
continue
|
||||
kept_ns = True
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
|
||||
def extract_tags_from_result(result: Any) -> list[str]:
|
||||
tags: list[str] = []
|
||||
if isinstance(result, models.PipeObject):
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._shared import (
|
||||
extract_tags_from_result, extract_title_from_result, extract_known_urls_from_result,
|
||||
merge_sequences, extract_relationships, extract_duration
|
||||
)
|
||||
from ._shared import collapse_namespace_tags
|
||||
from helper.local_library import read_sidecar, find_sidecar, write_sidecar, LocalLibraryDB
|
||||
from helper.utils import sha256_file
|
||||
from metadata import embed_metadata_in_file
|
||||
@@ -133,6 +134,31 @@ def _cleanup_sidecar_files(media_path: Path, *extra_paths: Optional[Path]) -> No
|
||||
continue
|
||||
|
||||
|
||||
def _show_local_result_table(file_hash: Optional[str], config: Dict[str, Any]) -> None:
|
||||
"""Run search-file by hash to display the newly added local file in a table."""
|
||||
if not file_hash:
|
||||
return
|
||||
try:
|
||||
from cmdlets import search_file as search_cmd
|
||||
temp_ctx = models.PipelineStageContext(0, 1)
|
||||
saved_ctx = ctx.get_stage_context()
|
||||
ctx.set_stage_context(temp_ctx)
|
||||
try:
|
||||
# Call the cmdlet exactly like the user would type: search-file "hash:...,store:local"
|
||||
search_cmd._run(None, [f"hash:{file_hash},store:local"], config)
|
||||
try:
|
||||
table = ctx.get_last_result_table()
|
||||
if table is not None:
|
||||
log("")
|
||||
log(table.format_plain())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ctx.set_stage_context(saved_ctx)
|
||||
except Exception as exc:
|
||||
debug(f"[add-file] Skipped search-file display: {exc}")
|
||||
|
||||
|
||||
def _persist_local_metadata(
|
||||
library_root: Path,
|
||||
dest_path: Path,
|
||||
@@ -209,7 +235,7 @@ def _handle_local_transfer(
|
||||
try:
|
||||
destination_root.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
log(f"❌ Cannot prepare destination directory {destination_root}: {exc}", file=sys.stderr)
|
||||
log(f"Cannot prepare destination directory {destination_root}: {exc}", file=sys.stderr)
|
||||
return 1, None
|
||||
|
||||
|
||||
@@ -234,8 +260,8 @@ def _handle_local_transfer(
|
||||
return f"title:{value}"
|
||||
return tag
|
||||
|
||||
tags_from_result = [normalize_title_tag(t) for t in tags_from_result]
|
||||
sidecar_tags = [normalize_title_tag(t) for t in sidecar_tags]
|
||||
tags_from_result = collapse_namespace_tags([normalize_title_tag(t) for t in tags_from_result], "title", prefer="last")
|
||||
sidecar_tags = collapse_namespace_tags([normalize_title_tag(t) for t in sidecar_tags], "title", prefer="last")
|
||||
|
||||
# Merge tags carefully: if URL has title tag, don't include sidecar title tags
|
||||
# This prevents duplicate title: tags when URL provides a title
|
||||
@@ -295,6 +321,7 @@ def _handle_local_transfer(
|
||||
else:
|
||||
# Ensure filename is the hash when adding to local storage
|
||||
resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path)
|
||||
hashed_move_done = False
|
||||
if resolved_hash:
|
||||
hashed_name = resolved_hash + media_path.suffix
|
||||
target_path = destination_root / hashed_name
|
||||
@@ -305,7 +332,13 @@ def _handle_local_transfer(
|
||||
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)
|
||||
hashed_move_done = True
|
||||
|
||||
if hashed_move_done and media_path.parent.samefile(destination_root):
|
||||
# Already placed at final destination with hash name; skip extra upload/move
|
||||
dest_file = str(media_path)
|
||||
else:
|
||||
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
|
||||
@@ -316,7 +349,7 @@ def _handle_local_transfer(
|
||||
|
||||
# If we have a title tag, keep it. Otherwise, derive from filename.
|
||||
has_title = any(str(t).strip().lower().startswith("title:") for t in merged_tags)
|
||||
final_tags = merged_tags
|
||||
final_tags = collapse_namespace_tags(merged_tags, "title", prefer="last")
|
||||
|
||||
if not has_title:
|
||||
filename_title = dest_path.stem.replace("_", " ").strip()
|
||||
@@ -326,7 +359,7 @@ def _handle_local_transfer(
|
||||
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}")
|
||||
_show_local_result_table(file_hash, config or {})
|
||||
else:
|
||||
debug(f"✅ Exported to destination: {dest_path}")
|
||||
return 0, dest_path
|
||||
@@ -390,9 +423,17 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
location = str(path_value)
|
||||
|
||||
# Get location from parsed args - now uses SharedArgs.STORAGE so key is "storage"
|
||||
location = parsed.get("storage")
|
||||
if location:
|
||||
location = str(location).lower().strip()
|
||||
storage_arg = parsed.get("storage")
|
||||
if location is None:
|
||||
location = storage_arg
|
||||
if location:
|
||||
location = str(location).lower().strip()
|
||||
elif storage_arg:
|
||||
# User provided both -path (as destination) and -storage; prefer explicit storage only if it matches
|
||||
storage_str = str(storage_arg).lower().strip()
|
||||
if storage_str != str(location).lower():
|
||||
log(f"❌ Conflicting destinations: -path '{location}' vs -storage '{storage_str}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get file provider from parsed args
|
||||
provider_name = parsed.get("provider")
|
||||
@@ -973,8 +1014,14 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except OSError as exc:
|
||||
log(f"Failed to delete sidecar: {exc}", file=sys.stderr)
|
||||
|
||||
log(f"✅ Successfully completed: {media_path.name} (hash={file_hash})", file=sys.stderr)
|
||||
|
||||
# Decide whether to surface search-file results at end of pipeline
|
||||
stage_ctx = ctx.get_stage_context()
|
||||
is_storage_target = location is not None
|
||||
should_display = is_storage_target and (stage_ctx is None or stage_ctx.is_last_stage)
|
||||
|
||||
if (not should_display) or not file_hash:
|
||||
log(f"Successfully completed: {media_path.name} (hash={file_hash})", file=sys.stderr)
|
||||
|
||||
# Emit result for Hydrus uploads so downstream commands know about it
|
||||
if location == 'hydrus':
|
||||
# Extract title from original result, fallback to filename if not available
|
||||
@@ -999,6 +1046,17 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Clear the stage table so downstream @N doesn't try to re-run download-data
|
||||
# Next stage will use these Hydrus file results, not format objects
|
||||
ctx.set_current_stage_table(None)
|
||||
|
||||
# If this is the last stage (or not in a pipeline), show the file via search-file
|
||||
if should_display and file_hash:
|
||||
try:
|
||||
from cmdlets import search_file as search_cmdlet
|
||||
search_cmdlet._run(None, [f"hash:{file_hash}"], config)
|
||||
except Exception:
|
||||
debug("search-file lookup after add-file failed", file=sys.stderr)
|
||||
elif file_hash:
|
||||
# Not displaying search results here, so report completion normally
|
||||
log(f"Successfully completed: {media_path.name} (hash={file_hash})", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from ._shared import normalize_result_input, filter_results_by_temp
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, has_sidecar, LocalLibraryDB
|
||||
from metadata import rename
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags
|
||||
from config import get_local_storage_path
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ def _refresh_tags_view(res: Any, hydrus_hash: Optional[str], file_hash: Optional
|
||||
target_hash = hydrus_hash or file_hash
|
||||
refresh_args: List[str] = []
|
||||
if target_hash:
|
||||
refresh_args = ["-hash", target_hash]
|
||||
refresh_args = ["-hash", target_hash, "-store", target_hash]
|
||||
|
||||
try:
|
||||
subject = ctx.get_last_result_subject()
|
||||
@@ -413,6 +413,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if new_tag not in existing_tags:
|
||||
existing_tags.append(new_tag)
|
||||
|
||||
# Ensure only one tag per namespace (e.g., single title:) with latest preferred
|
||||
existing_tags = collapse_namespace_tags(existing_tags, "title", prefer="last")
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -16,6 +16,35 @@ from config import get_local_storage_path
|
||||
from helper.local_library import LocalLibraryDB
|
||||
|
||||
|
||||
def _refresh_last_search(config: Dict[str, Any]) -> None:
|
||||
"""Re-run the last search-file to refresh the table after deletes."""
|
||||
try:
|
||||
source_cmd = ctx.get_last_result_table_source_command() if hasattr(ctx, "get_last_result_table_source_command") else None
|
||||
if source_cmd not in {"search-file", "search_file", "search"}:
|
||||
return
|
||||
|
||||
args = ctx.get_last_result_table_source_args() if hasattr(ctx, "get_last_result_table_source_args") else []
|
||||
try:
|
||||
from cmdlets import search_file as search_file_cmd # type: ignore
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Re-run the prior search to refresh items/table without disturbing history
|
||||
search_file_cmd._run(None, args, config)
|
||||
|
||||
# Set an overlay so action-command pipeline output displays the refreshed table
|
||||
try:
|
||||
new_table = ctx.get_last_result_table()
|
||||
new_items = ctx.get_last_result_items()
|
||||
subject = ctx.get_last_result_subject() if hasattr(ctx, "get_last_result_subject") else None
|
||||
if hasattr(ctx, "set_last_result_table_overlay") and new_table and new_items is not None:
|
||||
ctx.set_last_result_table_overlay(new_table, new_items, subject)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
debug(f"[delete_file] search refresh failed: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
|
||||
|
||||
def _cleanup_relationships(db_path: Path, file_hash: str) -> int:
|
||||
@@ -342,7 +371,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
for item in items:
|
||||
if _process_single_item(item, override_hash, conserve, lib_root, reason, config):
|
||||
success_count += 1
|
||||
|
||||
|
||||
if success_count > 0:
|
||||
_refresh_last_search(config)
|
||||
|
||||
return 0 if success_count > 0 else 1
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
|
||||
@@ -2484,7 +2484,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
||||
# Create result table for format display
|
||||
table = ResultTable(title=f"Available Formats - {probe_info.get('title', 'Unknown')}")
|
||||
|
||||
for fmt in formats:
|
||||
for idx, fmt in enumerate(formats, start=1):
|
||||
row = table.add_row()
|
||||
row.add_column("Format ID", fmt.get("format_id", ""))
|
||||
|
||||
@@ -2518,38 +2518,26 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
||||
if fmt.get("filesize"):
|
||||
size_mb = fmt["filesize"] / (1024 * 1024)
|
||||
row.add_column("Size", f"{size_mb:.1f} MB")
|
||||
|
||||
# Enable @N expansion to rerun download-data with -item idx
|
||||
row.set_selection_args(["-item", str(idx)])
|
||||
|
||||
# Set source command for @N expansion
|
||||
table.set_source_command("download-data", [url])
|
||||
|
||||
# Note: Row selection args are not set - users select with @N syntax directly
|
||||
|
||||
# Display table and emit as pipeline result
|
||||
# Display table
|
||||
log(str(table), flush=True)
|
||||
formats_displayed = True
|
||||
|
||||
# Store table for @N expansion so CLI can reconstruct commands
|
||||
# Uses separate current_stage_table instead of result history table
|
||||
pipeline_context.set_current_stage_table(table)
|
||||
|
||||
# Always emit formats so they can be selected with @N
|
||||
for i, fmt in enumerate(formats, 1):
|
||||
pipeline_context.emit({
|
||||
"format_id": fmt.get("format_id", ""),
|
||||
"format_string": fmt.get("format", ""),
|
||||
"resolution": fmt.get("resolution", ""),
|
||||
"vcodec": fmt.get("vcodec", ""),
|
||||
"acodec": fmt.get("acodec", ""),
|
||||
"ext": fmt.get("ext", ""),
|
||||
"filesize": fmt.get("filesize"),
|
||||
"source_url": url,
|
||||
"index": i,
|
||||
})
|
||||
debug(f"Use @N syntax to select a format and download")
|
||||
pipeline_context.set_last_result_table_overlay(table, formats)
|
||||
debug("Use @N to pick a format; pipeline paused until selection")
|
||||
else:
|
||||
log(f"✗ No formats available for this URL", file=sys.stderr)
|
||||
|
||||
continue # Skip download, just show formats
|
||||
# Stop pipeline here; selection via @N will re-run download-data with -item
|
||||
return 0
|
||||
|
||||
# ====== AUTO-DETECT MULTIPLE FORMATS ======
|
||||
# Check if multiple formats exist and handle based on -item flag
|
||||
@@ -2636,35 +2624,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
||||
|
||||
# Set source command for @N expansion
|
||||
table.set_source_command("download-data", [url])
|
||||
|
||||
|
||||
# Set row selection args so @N expands to "download-data URL -item N"
|
||||
for i in range(len(formats)):
|
||||
# i is 0-based index, but -item expects 1-based index
|
||||
table.set_row_selection_args(i, ["-item", str(i + 1)])
|
||||
|
||||
# Display table and emit formats so they can be selected with @N
|
||||
debug(str(table))
|
||||
|
||||
# Display table
|
||||
log(str(table), flush=True)
|
||||
debug(f"💡 Use @N syntax to select a format and download (e.g., @1)")
|
||||
|
||||
|
||||
# Store table for @N expansion so CLI can reconstruct commands
|
||||
pipeline_context.set_current_stage_table(table)
|
||||
|
||||
# Emit formats as pipeline results for @N selection
|
||||
for i, fmt in enumerate(formats, 1):
|
||||
pipeline_context.emit({
|
||||
"format_id": fmt.get("format_id", ""),
|
||||
"format_string": fmt.get("format", ""),
|
||||
"resolution": fmt.get("resolution", ""),
|
||||
"vcodec": fmt.get("vcodec", ""),
|
||||
"acodec": fmt.get("acodec", ""),
|
||||
"filesize": fmt.get("filesize"),
|
||||
"tbr": fmt.get("tbr"),
|
||||
"source_url": url,
|
||||
"index": i,
|
||||
})
|
||||
|
||||
pipeline_context.set_last_result_table_overlay(table, formats)
|
||||
|
||||
formats_displayed = True # Mark that we displayed formats
|
||||
continue # Skip download, user must select format via @N
|
||||
return 0 # Pause pipeline; user must select format via @N
|
||||
|
||||
debug(f"Downloading: {url}")
|
||||
|
||||
@@ -2951,41 +2925,30 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
|
||||
if downloaded_files or files_downloaded_directly > 0:
|
||||
total_files = len(downloaded_files) + files_downloaded_directly
|
||||
debug(f"✓ Successfully downloaded {total_files} file(s)")
|
||||
|
||||
# Create a result table for the downloaded files
|
||||
# This ensures that subsequent @N commands select from these files
|
||||
# instead of trying to expand the previous command (e.g. search-file)
|
||||
if downloaded_files:
|
||||
from result_table import ResultTable
|
||||
table = ResultTable("Downloaded Files")
|
||||
for i, file_path in enumerate(downloaded_files):
|
||||
# Ensure file_path is a Path object
|
||||
if isinstance(file_path, str):
|
||||
file_path = Path(file_path)
|
||||
|
||||
row = table.add_row()
|
||||
row.add_column("#", str(i + 1))
|
||||
row.add_column("File", file_path.name)
|
||||
row.add_column("Path", str(file_path))
|
||||
try:
|
||||
size_mb = file_path.stat().st_size / (1024*1024)
|
||||
row.add_column("Size", f"{size_mb:.1f} MB")
|
||||
except OSError:
|
||||
row.add_column("Size", "?")
|
||||
|
||||
# Set selection args to just the file path (or index if we want item selection)
|
||||
# For item selection fallback, we don't strictly need row args if source command is None
|
||||
# But setting them helps if we want to support command expansion later
|
||||
table.set_row_selection_args(i, [str(file_path)])
|
||||
|
||||
# Register the table but DO NOT set a source command
|
||||
# This forces CLI to use item-based selection (filtering the pipe)
|
||||
# instead of command expansion
|
||||
pipeline_context.set_last_result_table_overlay(table, downloaded_files)
|
||||
pipeline_context.set_current_stage_table(table)
|
||||
|
||||
# Also print the table so user sees what they got
|
||||
log(str(table), flush=True)
|
||||
|
||||
stage_ctx = pipeline_context.get_stage_context()
|
||||
should_display_results = stage_ctx is None or stage_ctx.is_last_stage
|
||||
|
||||
if downloaded_files and should_display_results:
|
||||
try:
|
||||
from cmdlets import search_file as search_cmdlet
|
||||
except Exception:
|
||||
search_cmdlet = None
|
||||
|
||||
if search_cmdlet:
|
||||
seen_hashes: set[str] = set()
|
||||
for file_entry in downloaded_files:
|
||||
path_obj = Path(file_entry) if not isinstance(file_entry, Path) else file_entry
|
||||
if not path_obj.is_file():
|
||||
continue
|
||||
file_hash = _compute_file_hash(path_obj)
|
||||
if file_hash and file_hash not in seen_hashes:
|
||||
seen_hashes.add(file_hash)
|
||||
search_cmdlet._run(None, [f"hash:{file_hash}"], config)
|
||||
else:
|
||||
debug("search-file not available; skipping post-download display")
|
||||
elif downloaded_files:
|
||||
debug("Skipping search-file display because downstream pipeline is present")
|
||||
|
||||
if db:
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence
|
||||
from pathlib import Path
|
||||
import shutil as _shutil
|
||||
import subprocess as _subprocess
|
||||
@@ -8,13 +8,15 @@ import json
|
||||
import sys
|
||||
import platform
|
||||
|
||||
import threading
|
||||
|
||||
from helper.logger import log, debug
|
||||
import uuid as _uuid
|
||||
import time as _time
|
||||
|
||||
from helper.progress import print_progress, print_final_progress
|
||||
from helper.http_client import HTTPClient
|
||||
from helper.mpv_ipc import get_ipc_pipe_path, send_to_mpv
|
||||
from helper.mpv_ipc import get_ipc_pipe_path, send_to_mpv, MPV_LUA_SCRIPT_PATH
|
||||
import fnmatch as _fnmatch
|
||||
|
||||
from . import register
|
||||
@@ -25,6 +27,9 @@ from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, create_
|
||||
from config import resolve_output_dir, get_hydrus_url, get_hydrus_access_key
|
||||
from helper.alldebrid import AllDebridClient
|
||||
|
||||
DEFAULT_DEBRID_WAIT_TIMEOUT = 600
|
||||
DEBRID_WORKER_PREFIX = "debrid_"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -83,70 +88,13 @@ def _handle_alldebrid_pipe(config: Dict[str, Any], args: Sequence[str]) -> int:
|
||||
log("✗ No valid magnet IDs in pipe", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get API key
|
||||
from config import get_debrid_api_key
|
||||
api_key = get_debrid_api_key(config)
|
||||
if not api_key:
|
||||
log("✗ AllDebrid API key not configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Download from each magnet
|
||||
client = AllDebridClient(api_key)
|
||||
total_files = 0
|
||||
failed_files = 0
|
||||
|
||||
log(f"Processing {len(magnets)} magnet(s)...", file=sys.stderr)
|
||||
|
||||
for magnet_id in magnets:
|
||||
try:
|
||||
# Fetch magnet files using magnet_status with include_files
|
||||
magnet_info = client.magnet_status(magnet_id, include_files=True)
|
||||
|
||||
files_list = _extract_files_from_magnet(magnet_info, file_filter)
|
||||
|
||||
if not files_list:
|
||||
log(f"⊘ No files in magnet {magnet_id}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
log(f"✓ Found {len(files_list)} file(s) in magnet {magnet_id}", file=sys.stderr)
|
||||
|
||||
# Download each file
|
||||
for file_info in files_list:
|
||||
try:
|
||||
link = file_info['link']
|
||||
filename = file_info['name']
|
||||
|
||||
# Unlock link to get direct URL
|
||||
try:
|
||||
direct_url = client.unlock_link(link)
|
||||
if not direct_url:
|
||||
log(f"✗ Failed to unlock link for {filename}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"✗ Error unlocking link: {e}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
continue
|
||||
|
||||
# Download file
|
||||
output_file = out_path / filename
|
||||
if _download_file_from_alldebrid(direct_url, output_file, filename, file_info['size']):
|
||||
log(f"✓ Downloaded: {filename}", file=sys.stderr)
|
||||
total_files += 1
|
||||
else:
|
||||
log(f"✗ Failed to download: {filename}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
|
||||
except Exception as e:
|
||||
log(f"✗ Error downloading file: {e}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
|
||||
except Exception as e:
|
||||
log(f"✗ Error processing magnet {magnet_id}: {e}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
|
||||
log(f"✓ Download complete: {total_files} file(s) downloaded, {failed_files} failed", file=sys.stderr)
|
||||
return 0 if failed_files == 0 else 1
|
||||
return _queue_alldebrid_worker(
|
||||
config=config,
|
||||
output_dir=out_path,
|
||||
magnet_ids=magnets,
|
||||
title=f"AllDebrid pipe ({len(magnets)} magnet{'s' if len(magnets) != 1 else ''})",
|
||||
file_filter=file_filter,
|
||||
)
|
||||
|
||||
|
||||
def _extract_files_from_magnet(magnet_info: Dict[str, Any], filter_pattern: Optional[str] = None) -> list:
|
||||
@@ -219,6 +167,202 @@ def _download_file_from_alldebrid(url: str, output_path: Path, filename: str, fi
|
||||
return False
|
||||
|
||||
|
||||
def _queue_alldebrid_worker(
|
||||
config: Dict[str, Any],
|
||||
output_dir: Path,
|
||||
magnet_ids: Sequence[int],
|
||||
title: str,
|
||||
file_filter: Optional[str] = None,
|
||||
wait_timeout: int = DEFAULT_DEBRID_WAIT_TIMEOUT,
|
||||
):
|
||||
"""Spawn a background worker to download AllDebrid magnets."""
|
||||
from config import get_debrid_api_key
|
||||
|
||||
if not magnet_ids:
|
||||
log("✗ No magnet IDs provided for AllDebrid download", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
api_key = get_debrid_api_key(config)
|
||||
if not api_key:
|
||||
log("✗ AllDebrid API key not configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
worker_id = f"{DEBRID_WORKER_PREFIX}{_uuid.uuid4().hex[:8]}"
|
||||
worker_manager = config.get('_worker_manager')
|
||||
if worker_manager:
|
||||
try:
|
||||
worker_manager.track_worker(
|
||||
worker_id,
|
||||
worker_type="download_debrid",
|
||||
title=title,
|
||||
description=f"AllDebrid download for {title}",
|
||||
pipe=ctx.get_current_command_text(),
|
||||
)
|
||||
except Exception as exc:
|
||||
debug(f"⚠ Failed to register AllDebrid worker: {exc}")
|
||||
worker_manager = None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_alldebrid_download_worker,
|
||||
args=(
|
||||
worker_id,
|
||||
api_key,
|
||||
output_dir,
|
||||
list(magnet_ids),
|
||||
file_filter,
|
||||
title,
|
||||
worker_manager,
|
||||
wait_timeout,
|
||||
),
|
||||
daemon=False,
|
||||
name=f"AllDebridWorker_{worker_id}"
|
||||
)
|
||||
thread.start()
|
||||
|
||||
ctx.emit({
|
||||
'worker_id': worker_id,
|
||||
'worker_type': 'download_debrid',
|
||||
'status': 'running',
|
||||
'message': f"{title} (queued)",
|
||||
})
|
||||
|
||||
log(f"🌀 AllDebrid download queued (worker {worker_id})", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_alldebrid_download_worker(
|
||||
worker_id: str,
|
||||
api_key: str,
|
||||
output_dir: Path,
|
||||
magnet_ids: List[int],
|
||||
file_filter: Optional[str],
|
||||
title: str,
|
||||
worker_manager: Optional[Any],
|
||||
wait_timeout: int,
|
||||
):
|
||||
"""Worker entrypoint that polls AllDebrid and downloads magnet files."""
|
||||
def log_progress(message: str) -> None:
|
||||
safe = f"[Worker {worker_id}] {message}"
|
||||
debug(safe)
|
||||
if worker_manager:
|
||||
try:
|
||||
worker_manager.log_step(worker_id, message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
client = AllDebridClient(api_key)
|
||||
except Exception as exc:
|
||||
log_progress(f"✗ Failed to initialize AllDebrid client: {exc}")
|
||||
if worker_manager:
|
||||
try:
|
||||
worker_manager.finish_worker(worker_id, "failed", str(exc))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
total_downloaded = 0
|
||||
total_failed = 0
|
||||
|
||||
for magnet_id in magnet_ids:
|
||||
log_progress(f"⧗ Processing magnet {magnet_id}")
|
||||
try:
|
||||
status_info = client.magnet_status(magnet_id)
|
||||
except Exception as exc:
|
||||
log_progress(f"✗ Failed to query magnet {magnet_id}: {exc}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
ready_status = _wait_for_magnet_ready(client, magnet_id, log_progress, wait_timeout)
|
||||
except Exception as exc:
|
||||
log_progress(f"✗ Magnet {magnet_id} did not become ready: {exc}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
magnet_info = client.magnet_status(magnet_id, include_files=True)
|
||||
except Exception as exc:
|
||||
log_progress(f"✗ Failed to list files for magnet {magnet_id}: {exc}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
files_list = _extract_files_from_magnet(magnet_info, file_filter)
|
||||
if not files_list:
|
||||
log_progress(f"⊘ Magnet {magnet_id} has no files")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
for file_info in files_list:
|
||||
name = file_info.get('name', 'unknown')
|
||||
log_progress(f"⇓ Downloading {name}")
|
||||
link = file_info.get('link')
|
||||
if not link:
|
||||
log_progress(f"✗ Missing link for {name}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
direct_url = client.unlock_link(link)
|
||||
except Exception as exc:
|
||||
log_progress(f"✗ Failed to unlock {name}: {exc}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
output_file = output_dir / name
|
||||
if _download_file_from_alldebrid(direct_url, output_file, name, file_info.get('size', 0)):
|
||||
total_downloaded += 1
|
||||
else:
|
||||
total_failed += 1
|
||||
|
||||
if total_downloaded or total_failed:
|
||||
summary = f"{total_downloaded} file(s) downloaded, {total_failed} failed"
|
||||
else:
|
||||
summary = "No files were processed"
|
||||
|
||||
log(f"✓ AllDebrid worker {worker_id}: {summary}", file=sys.stderr)
|
||||
if worker_manager:
|
||||
status = "success" if total_downloaded > 0 else "failed"
|
||||
try:
|
||||
worker_manager.finish_worker(worker_id, status, summary if status == "failed" else "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _wait_for_magnet_ready(
|
||||
client: AllDebridClient,
|
||||
magnet_id: int,
|
||||
log_progress: Callable[[str], None],
|
||||
wait_timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
elapsed = 0
|
||||
last_report = -5
|
||||
while elapsed < wait_timeout:
|
||||
try:
|
||||
status = client.magnet_status(magnet_id)
|
||||
except Exception as exc:
|
||||
log_progress(f"⚠ Live status check failed: {exc}")
|
||||
_time.sleep(2)
|
||||
elapsed += 2
|
||||
continue
|
||||
|
||||
status_code = int(status.get('statusCode', -1))
|
||||
if status_code == 4:
|
||||
return status
|
||||
if status_code >= 5:
|
||||
raise RuntimeError(status.get('status', f"Failed code {status_code}"))
|
||||
if elapsed - last_report >= 5:
|
||||
downloaded = status.get('downloaded', 0)
|
||||
size = status.get('size', 0)
|
||||
percent = (downloaded / size * 100) if size else 0
|
||||
log_progress(f"⧗ {status.get('status', 'processing')} — {percent:.1f}%")
|
||||
last_report = elapsed
|
||||
_time.sleep(2)
|
||||
elapsed += 2
|
||||
raise TimeoutError(f"Magnet {magnet_id} not ready after {wait_timeout}s")
|
||||
|
||||
|
||||
def _is_playable_in_mpv(file_path_or_ext: str, mime_type: Optional[str] = None) -> bool:
|
||||
"""Check if file can be played in MPV based on extension or mime type."""
|
||||
from helper.utils_constant import mime_maps
|
||||
@@ -265,8 +409,13 @@ def _play_in_mpv(file_url: str, file_title: str, is_stream: bool = False, header
|
||||
ipc_pipe = get_ipc_pipe_path()
|
||||
debug(f"[get-file] Starting new MPV instance (pipe: {ipc_pipe})", file=sys.stderr)
|
||||
|
||||
# Build command - start MPV without a file initially, just with IPC server
|
||||
# Build command - start MPV without a file initially, just with IPC server and our Lua helper
|
||||
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}']
|
||||
try:
|
||||
if MPV_LUA_SCRIPT_PATH and Path(MPV_LUA_SCRIPT_PATH).exists():
|
||||
cmd.append(f"--scripts-append={MPV_LUA_SCRIPT_PATH}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if headers:
|
||||
# Format headers for command line
|
||||
@@ -468,10 +617,12 @@ def _handle_hydrus_file(file_hash: Optional[str], file_title: str, config: Dict[
|
||||
elif force_mpv or (is_media and mpv_available):
|
||||
# Auto-play in MPV for media files (if available), or user requested it
|
||||
if _play_in_mpv(stream_url, file_title, is_stream=True, headers=headers):
|
||||
# Show pipe menu instead of emitting result for display
|
||||
# This allows immediate @N selection from the playlist
|
||||
from . import pipe
|
||||
pipe._run(None, [], config)
|
||||
# Show unified MPV playlist view (reuse cmdnats.pipe display)
|
||||
try:
|
||||
from cmdnats import pipe as mpv_pipe
|
||||
mpv_pipe._run(None, [], config)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
else:
|
||||
# Fall back to browser
|
||||
@@ -580,10 +731,12 @@ def _handle_local_file(file_path: Optional[str], file_title: str, config: Dict[s
|
||||
elif force_mpv or (is_media and mpv_available):
|
||||
# Auto-play in MPV for media files (if available), or user requested it
|
||||
if _play_in_mpv(file_path, file_title, is_stream=False):
|
||||
# Show pipe menu instead of emitting result for display
|
||||
# This allows immediate @N selection from the playlist
|
||||
from . import pipe
|
||||
pipe._run(None, [], config)
|
||||
# Show unified MPV playlist view (reuse cmdnats.pipe display)
|
||||
try:
|
||||
from cmdnats import pipe as mpv_pipe
|
||||
mpv_pipe._run(None, [], config)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
else:
|
||||
# Fall back to default application
|
||||
@@ -661,94 +814,12 @@ def _handle_debrid_file(magnet_id: int, magnet_title: str, config: Dict[str, Any
|
||||
log(f"✗ Error creating output directory: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Get API key
|
||||
from config import get_debrid_api_key
|
||||
api_key = get_debrid_api_key(config)
|
||||
if not api_key:
|
||||
log("✗ AllDebrid API key not configured in config.json", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
client = AllDebridClient(api_key)
|
||||
|
||||
debug(f"[get-file] Downloading magnet {magnet_id}: {magnet_title}", file=sys.stderr)
|
||||
|
||||
# Fetch magnet files
|
||||
try:
|
||||
magnet_info = client.magnet_status(magnet_id, include_files=True)
|
||||
except Exception as e:
|
||||
log(f"✗ Failed to fetch magnet files: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Extract files from magnet
|
||||
files_list = _extract_files_from_magnet(magnet_info)
|
||||
|
||||
if not files_list:
|
||||
log(f"✗ No files in magnet {magnet_id}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
log(f"✓ Found {len(files_list)} file(s) in magnet {magnet_id}", file=sys.stderr)
|
||||
|
||||
# Download each file
|
||||
total_files = 0
|
||||
failed_files = 0
|
||||
|
||||
for file_info in files_list:
|
||||
try:
|
||||
link = file_info['link']
|
||||
filename = file_info['name']
|
||||
file_size = file_info['size']
|
||||
|
||||
# Unlock link to get direct URL
|
||||
try:
|
||||
direct_url = client.unlock_link(link)
|
||||
if not direct_url:
|
||||
log(f"✗ Failed to unlock link for {filename}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
log(f"✗ Error unlocking link: {e}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
continue
|
||||
|
||||
# Download file
|
||||
output_file = out_path / filename
|
||||
if _download_file_from_alldebrid(direct_url, output_file, filename, file_size):
|
||||
log(f"✓ Downloaded: {filename}", file=sys.stderr)
|
||||
total_files += 1
|
||||
else:
|
||||
log(f"✗ Failed to download: {filename}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
|
||||
except Exception as e:
|
||||
log(f"✗ Error downloading file: {e}", file=sys.stderr)
|
||||
failed_files += 1
|
||||
|
||||
log(f"✓ Download complete: {total_files} file(s) downloaded, {failed_files} failed", file=sys.stderr)
|
||||
|
||||
if total_files > 0:
|
||||
# Emit result for downstream processing
|
||||
result_dict = create_pipe_object_result(
|
||||
source='debrid',
|
||||
identifier=str(magnet_id),
|
||||
file_path=str(out_path),
|
||||
cmdlet_name='get-file',
|
||||
title=magnet_title,
|
||||
extra={
|
||||
'magnet_id': magnet_id,
|
||||
'files_downloaded': total_files,
|
||||
'download_dir': str(out_path)
|
||||
}
|
||||
)
|
||||
ctx.emit(result_dict)
|
||||
|
||||
return 0 if failed_files == 0 else 1
|
||||
|
||||
except Exception as e:
|
||||
log(f"✗ Error processing debrid download: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 1
|
||||
return _queue_alldebrid_worker(
|
||||
config=config,
|
||||
output_dir=out_path,
|
||||
magnet_ids=[magnet_id],
|
||||
title=magnet_title or f"magnet {magnet_id}",
|
||||
)
|
||||
|
||||
|
||||
@register(["get-file"]) # primary name
|
||||
@@ -1043,7 +1114,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
else:
|
||||
base_name = 'export'
|
||||
|
||||
local_target = get_field(result, 'target', None)
|
||||
# Accept multiple path-ish fields so @ selection from MPV playlist rows or ad-hoc dicts still resolve.
|
||||
local_target = (
|
||||
get_field(result, 'target', None)
|
||||
or get_field(result, 'path', None)
|
||||
or get_field(result, 'file_path', None)
|
||||
or get_field(result, 'filename', None)
|
||||
)
|
||||
is_url = isinstance(local_target, str) and local_target.startswith(('http://', 'https://'))
|
||||
# Establish file hash (prefer -hash override when provided and valid)
|
||||
if hash_spec and looks_like_hash(hash_spec):
|
||||
@@ -1580,19 +1657,22 @@ def _handle_ytdlp_download(url: str, title: str, config: Dict[str, Any], args: S
|
||||
if not force_local:
|
||||
# Default: Stream to MPV
|
||||
if _play_in_mpv(url, title, is_stream=True):
|
||||
from . import pipe
|
||||
pipe._run(None, [], config)
|
||||
return 0
|
||||
try:
|
||||
from cmdnats import pipe as mpv_pipe
|
||||
mpv_pipe._run(None, [], config)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
else:
|
||||
# Fallback to browser
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
debug(f"[get-file] Opened in browser: {title}", file=sys.stderr)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
# Fallback to browser
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
debug(f"[get-file] Opened in browser: {title}", file=sys.stderr)
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
# Download mode
|
||||
try:
|
||||
|
||||
@@ -10,7 +10,85 @@ import mimetypes
|
||||
import os
|
||||
|
||||
from helper import hydrus as hydrus_wrapper
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from ._shared import Cmdlet, CmdletArg, normalize_hash
|
||||
from config import get_local_storage_path
|
||||
import pipeline as ctx
|
||||
from result_table import ResultTable
|
||||
|
||||
|
||||
def _extract_imported_ts(meta: Dict[str, Any]) -> Optional[int]:
|
||||
"""Extract an imported timestamp from Hydrus metadata if available."""
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
|
||||
# Prefer explicit time_imported if present
|
||||
explicit = meta.get("time_imported")
|
||||
if isinstance(explicit, (int, float)):
|
||||
return int(explicit)
|
||||
|
||||
file_services = meta.get("file_services")
|
||||
if isinstance(file_services, dict):
|
||||
current = file_services.get("current")
|
||||
if isinstance(current, dict):
|
||||
numeric = [int(v) for v in current.values() if isinstance(v, (int, float))]
|
||||
if numeric:
|
||||
return min(numeric)
|
||||
return None
|
||||
|
||||
|
||||
def _format_imported(ts: Optional[int]) -> str:
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
import datetime as _dt
|
||||
return _dt.datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_table_row(title: str, origin: str, path: str, mime: str, size_bytes: Optional[int], dur_seconds: Optional[int], imported_ts: Optional[int], urls: list[str], hash_value: Optional[str], pages: Optional[int] = None) -> Dict[str, Any]:
|
||||
size_mb = None
|
||||
if isinstance(size_bytes, int):
|
||||
try:
|
||||
size_mb = int(size_bytes / (1024 * 1024))
|
||||
except Exception:
|
||||
size_mb = None
|
||||
|
||||
dur_int = int(dur_seconds) if isinstance(dur_seconds, (int, float)) else None
|
||||
pages_int = int(pages) if isinstance(pages, (int, float)) else None
|
||||
imported_label = _format_imported(imported_ts)
|
||||
|
||||
duration_label = "Duration(s)"
|
||||
duration_value = str(dur_int) if dur_int is not None else ""
|
||||
if mime and mime.lower().startswith("application/pdf"):
|
||||
duration_label = "Pages"
|
||||
duration_value = str(pages_int) if pages_int is not None else ""
|
||||
|
||||
columns = [
|
||||
("Title", title or ""),
|
||||
("Hash", hash_value or ""),
|
||||
("MIME", mime or ""),
|
||||
("Size(MB)", str(size_mb) if size_mb is not None else ""),
|
||||
(duration_label, duration_value),
|
||||
("Imported", imported_label),
|
||||
("Store", origin or ""),
|
||||
]
|
||||
|
||||
return {
|
||||
"title": title or path,
|
||||
"path": path,
|
||||
"origin": origin,
|
||||
"mime": mime,
|
||||
"size_bytes": size_bytes,
|
||||
"duration_seconds": dur_int,
|
||||
"pages": pages_int,
|
||||
"imported_ts": imported_ts,
|
||||
"imported": imported_label,
|
||||
"hash": hash_value,
|
||||
"known_urls": urls,
|
||||
"columns": columns,
|
||||
}
|
||||
|
||||
|
||||
def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
@@ -69,43 +147,50 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if not mime_type:
|
||||
mime_type = "unknown"
|
||||
|
||||
# Get file size
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
except Exception:
|
||||
file_size = None
|
||||
|
||||
# Try to get duration if it's a media file
|
||||
# Pull metadata from local DB if available (for imported timestamp, duration, etc.)
|
||||
db_metadata = None
|
||||
library_root = get_local_storage_path(config)
|
||||
if library_root:
|
||||
try:
|
||||
with LocalLibraryDB(library_root) as db:
|
||||
db_metadata = db.get_metadata(file_path) or None
|
||||
except Exception:
|
||||
db_metadata = None
|
||||
|
||||
# Get file size (prefer DB size if present)
|
||||
file_size = None
|
||||
if isinstance(db_metadata, dict) and isinstance(db_metadata.get("size"), int):
|
||||
file_size = db_metadata.get("size")
|
||||
else:
|
||||
try:
|
||||
file_size = file_path.stat().st_size
|
||||
except Exception:
|
||||
file_size = None
|
||||
|
||||
# Duration/pages
|
||||
duration_seconds = None
|
||||
try:
|
||||
# Try to use ffprobe if available
|
||||
import subprocess
|
||||
result_proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result_proc.returncode == 0 and result_proc.stdout.strip():
|
||||
try:
|
||||
pages = None
|
||||
if isinstance(db_metadata, dict):
|
||||
if isinstance(db_metadata.get("duration"), (int, float)):
|
||||
duration_seconds = float(db_metadata.get("duration"))
|
||||
if isinstance(db_metadata.get("pages"), (int, float)):
|
||||
pages = int(db_metadata.get("pages"))
|
||||
|
||||
if duration_seconds is None and mime_type and mime_type.startswith("video"):
|
||||
try:
|
||||
import subprocess
|
||||
result_proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result_proc.returncode == 0 and result_proc.stdout.strip():
|
||||
duration_seconds = float(result_proc.stdout.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get format helpers from search module
|
||||
try:
|
||||
from .search_file import _format_size as _fmt_size
|
||||
from .search_file import _format_duration as _fmt_dur
|
||||
except Exception:
|
||||
_fmt_size = lambda x: str(x) if x is not None else ""
|
||||
_fmt_dur = lambda x: str(x) if x is not None else ""
|
||||
|
||||
size_label = _fmt_size(file_size) if file_size is not None else ""
|
||||
dur_label = _fmt_dur(duration_seconds) if duration_seconds is not None else ""
|
||||
|
||||
# Get known URLs from sidecar or result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Known URLs from sidecar or result
|
||||
urls = []
|
||||
sidecar_path = Path(str(file_path) + '.tags')
|
||||
if sidecar_path.exists():
|
||||
@@ -119,30 +204,45 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
urls.append(url_value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to result URLs if not in sidecar
|
||||
|
||||
if not urls:
|
||||
urls_from_result = get_field(result, "known_urls", None) or get_field(result, "urls", None)
|
||||
if isinstance(urls_from_result, list):
|
||||
urls.extend([str(u).strip() for u in urls_from_result if u])
|
||||
|
||||
# Display local file metadata
|
||||
log(f"PATH: {file_path}")
|
||||
if hash_hex:
|
||||
log(f"HASH: {hash_hex}")
|
||||
if mime_type:
|
||||
log(f"MIME: {mime_type}")
|
||||
if size_label:
|
||||
log(f"Size: {size_label}")
|
||||
if dur_label:
|
||||
log(f"Duration: {dur_label}")
|
||||
if urls:
|
||||
log("URLs:")
|
||||
for url in urls:
|
||||
log(f" {url}")
|
||||
|
||||
|
||||
imported_ts = None
|
||||
if isinstance(db_metadata, dict):
|
||||
ts = db_metadata.get("time_imported") or db_metadata.get("time_added")
|
||||
if isinstance(ts, (int, float)):
|
||||
imported_ts = int(ts)
|
||||
elif isinstance(ts, str):
|
||||
try:
|
||||
import datetime as _dt
|
||||
imported_ts = int(_dt.datetime.fromisoformat(ts).timestamp())
|
||||
except Exception:
|
||||
imported_ts = None
|
||||
|
||||
row = _build_table_row(
|
||||
title=file_path.name,
|
||||
origin="local",
|
||||
path=str(file_path),
|
||||
mime=mime_type or "",
|
||||
size_bytes=int(file_size) if isinstance(file_size, int) else None,
|
||||
dur_seconds=duration_seconds,
|
||||
imported_ts=imported_ts,
|
||||
urls=urls,
|
||||
hash_value=hash_hex,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
table_title = file_path.name
|
||||
table = ResultTable(table_title)
|
||||
table.set_source_command("get-metadata", list(_args))
|
||||
table.add_result(row)
|
||||
ctx.set_last_result_table_overlay(table, [row], row)
|
||||
ctx.emit(row)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
# Fall through to Hydrus if local file handling fails
|
||||
pass
|
||||
|
||||
@@ -191,41 +291,37 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
inner = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else None
|
||||
if duration_value is None and isinstance(inner, dict):
|
||||
duration_value = inner.get("duration")
|
||||
|
||||
imported_ts = _extract_imported_ts(meta)
|
||||
|
||||
try:
|
||||
from .search_file import _format_size as _fmt_size
|
||||
from .search_file import _format_duration as _fmt_dur
|
||||
from .search_file import _hydrus_duration_seconds as _dur_secs
|
||||
except Exception:
|
||||
_fmt_size = lambda x: str(x) if x is not None else ""
|
||||
_dur_secs = lambda x: x
|
||||
_fmt_dur = lambda x: str(x) if x is not None else ""
|
||||
|
||||
|
||||
dur_seconds = _dur_secs(duration_value)
|
||||
dur_label = _fmt_dur(dur_seconds) if dur_seconds is not None else ""
|
||||
size_label = _fmt_size(size)
|
||||
|
||||
# Display Hydrus file metadata
|
||||
log(f"PATH: hydrus://file/{hash_hex}")
|
||||
log(f"Hash: {hash_hex}")
|
||||
if mime:
|
||||
log(f"MIME: {mime}")
|
||||
if dur_label:
|
||||
log(f"Duration: {dur_label}")
|
||||
if size_label:
|
||||
log(f"Size: {size_label}")
|
||||
|
||||
urls = meta.get("known_urls") or meta.get("urls")
|
||||
if isinstance(urls, list) and urls:
|
||||
log("URLs:")
|
||||
for url in urls:
|
||||
try:
|
||||
text = str(url).strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if text:
|
||||
log(f" {text}")
|
||||
|
||||
urls = [str(u).strip() for u in urls] if isinstance(urls, list) else []
|
||||
|
||||
row = _build_table_row(
|
||||
title=hash_hex,
|
||||
origin="hydrus",
|
||||
path=f"hydrus://file/{hash_hex}",
|
||||
mime=mime or "",
|
||||
size_bytes=int(size) if isinstance(size, int) else None,
|
||||
dur_seconds=int(dur_seconds) if isinstance(dur_seconds, (int, float)) else None,
|
||||
imported_ts=imported_ts,
|
||||
urls=urls,
|
||||
hash_value=hash_hex,
|
||||
pages=None,
|
||||
)
|
||||
|
||||
table = ResultTable(hash_hex or "Metadata")
|
||||
table.set_source_command("get-metadata", list(_args))
|
||||
table.add_result(row)
|
||||
ctx.set_last_result_table_overlay(table, [row], row)
|
||||
ctx.emit(row)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,15 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Initialize results collection
|
||||
found_relationships = [] # List of dicts: {hash, type, title, path, origin}
|
||||
source_title = "Unknown"
|
||||
|
||||
def _add_relationship(entry: Dict[str, Any]) -> None:
|
||||
"""Add relationship if not already present by hash or path."""
|
||||
for existing in found_relationships:
|
||||
if entry.get("hash") and str(existing.get("hash", "")).lower() == str(entry["hash"]).lower():
|
||||
return
|
||||
if entry.get("path") and str(existing.get("path", "")).lower() == str(entry["path"]).lower():
|
||||
return
|
||||
found_relationships.append(entry)
|
||||
|
||||
# Check for local file first
|
||||
file_path = None
|
||||
@@ -116,9 +125,10 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
except Exception:
|
||||
title = resolved_path.stem
|
||||
|
||||
found_relationships.append({
|
||||
entry_type = "king" if rel_type.lower() == "alt" else rel_type
|
||||
_add_relationship({
|
||||
"hash": h,
|
||||
"type": rel_type,
|
||||
"type": entry_type,
|
||||
"title": title,
|
||||
"path": path,
|
||||
"origin": "local"
|
||||
@@ -136,7 +146,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
print(f"[DEBUG] Parent path obj: {parent_path_obj}", file=sys.stderr)
|
||||
|
||||
# Also add the king/parent itself if not already in results
|
||||
if not any(str(r['hash']).lower() == str(path).lower() for r in found_relationships):
|
||||
existing_parent = None
|
||||
for r in found_relationships:
|
||||
if str(r.get('hash', '')).lower() == str(path).lower() or str(r.get('path', '')).lower() == str(path).lower():
|
||||
existing_parent = r
|
||||
break
|
||||
if not existing_parent:
|
||||
parent_title = parent_path_obj.stem
|
||||
try:
|
||||
parent_tags = db.get_tags(parent_path_obj)
|
||||
@@ -148,7 +163,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
pass
|
||||
|
||||
print(f"[DEBUG] ➕ Adding king/parent to results: {parent_title}", file=sys.stderr)
|
||||
found_relationships.append({
|
||||
_add_relationship({
|
||||
"hash": str(path),
|
||||
"type": "king" if rel_type.lower() == "alt" else rel_type,
|
||||
"title": parent_title,
|
||||
@@ -157,11 +172,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
})
|
||||
else:
|
||||
# If already in results, ensure it's marked as king if appropriate
|
||||
for r in found_relationships:
|
||||
if str(r['hash']).lower() == str(path).lower():
|
||||
if rel_type.lower() == "alt":
|
||||
r['type'] = "king"
|
||||
break
|
||||
if rel_type.lower() == "alt":
|
||||
existing_parent['type'] = "king"
|
||||
|
||||
# 1. Check forward relationships from parent (siblings)
|
||||
parent_metadata = db.get_metadata(parent_path_obj)
|
||||
@@ -185,13 +197,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
print(f"[DEBUG] ⏭️ Hash doesn't resolve, skipping: {child_h}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Skip the current file we're querying
|
||||
if str(child_path_obj).lower() == str(path_obj).lower():
|
||||
print(f"[DEBUG] ⏭️ Skipping current file: {child_path_obj}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Check if already added (case-insensitive hash check)
|
||||
if any(str(r['hash']).lower() == str(child_h).lower() for r in found_relationships):
|
||||
# Check if already added (case-insensitive hash/path check)
|
||||
if any(str(r.get('hash', '')).lower() == str(child_h).lower() or str(r.get('path', '')).lower() == str(child_path_obj).lower() for r in found_relationships):
|
||||
print(f"[DEBUG] ⏭️ Already in results: {child_h}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
@@ -207,7 +214,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
pass
|
||||
|
||||
print(f"[DEBUG] ➕ Adding sibling: {child_title}", file=sys.stderr)
|
||||
found_relationships.append({
|
||||
_add_relationship({
|
||||
"hash": child_h,
|
||||
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
|
||||
"title": child_title,
|
||||
@@ -226,13 +233,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
child_type = child['type']
|
||||
print(f"[DEBUG] Reverse child: {child_path}, type: {child_type}", file=sys.stderr)
|
||||
|
||||
# Skip the current file
|
||||
if str(child_path).lower() == str(path_obj).lower():
|
||||
print(f"[DEBUG] ⏭️ Skipping self", file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Skip if already added (check by path, case-insensitive)
|
||||
if any(str(r.get('path', '')).lower() == str(child_path).lower() for r in found_relationships):
|
||||
# Skip if already added (check by path/hash, case-insensitive)
|
||||
if any(str(r.get('path', '')).lower() == str(child_path).lower() or str(r.get('hash', '')).lower() == str(child_path).lower() for r in found_relationships):
|
||||
print(f"[DEBUG] ⏭️ Already in results: {child_path}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
@@ -248,7 +250,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
pass
|
||||
|
||||
print(f"[DEBUG] ➕ Adding reverse sibling: {child_title}", file=sys.stderr)
|
||||
found_relationships.append({
|
||||
_add_relationship({
|
||||
"hash": child_path,
|
||||
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
|
||||
"title": child_title,
|
||||
|
||||
@@ -12,8 +12,8 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from helper.logger import log
|
||||
from helper.metadata_search import get_metadata_provider
|
||||
from helper.logger import log, debug
|
||||
from helper.metadata_search import get_metadata_provider, list_metadata_providers
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
@@ -475,6 +475,21 @@ def _extract_scrapable_identifiers(tags_list: List[str]) -> Dict[str, str]:
|
||||
return identifiers
|
||||
|
||||
|
||||
def _extract_tag_value(tags_list: List[str], namespace: str) -> Optional[str]:
|
||||
"""Get first tag value for a namespace (e.g., artist:, title:)."""
|
||||
ns = namespace.lower()
|
||||
for tag in tags_list:
|
||||
if not isinstance(tag, str) or ':' not in tag:
|
||||
continue
|
||||
prefix, _, value = tag.partition(':')
|
||||
if prefix.strip().lower() != ns:
|
||||
continue
|
||||
candidate = value.strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _scrape_url_metadata(url: str) -> Tuple[Optional[str], List[str], List[Tuple[str, str]], List[Dict[str, Any]]]:
|
||||
"""Scrape metadata from a URL using yt-dlp.
|
||||
|
||||
@@ -1012,6 +1027,25 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
--emit: Emit result without interactive prompt (quiet mode)
|
||||
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks)
|
||||
"""
|
||||
args_list = [str(arg) for arg in (args or [])]
|
||||
raw_args = list(args_list)
|
||||
|
||||
# Support numeric selection tokens (e.g., "@1" leading to argument "1") without treating
|
||||
# them as hash overrides. This lets users pick from the most recent table overlay/results.
|
||||
if len(args_list) == 1:
|
||||
token = args_list[0]
|
||||
if not token.startswith("-") and token.isdigit():
|
||||
try:
|
||||
idx = int(token) - 1
|
||||
items_pool = ctx.get_last_result_items()
|
||||
if 0 <= idx < len(items_pool):
|
||||
result = items_pool[idx]
|
||||
args_list = []
|
||||
debug(f"[get_tag] Resolved numeric selection arg {token} -> last_result_items[{idx}]")
|
||||
else:
|
||||
debug(f"[get_tag] Numeric selection arg {token} out of range (items={len(items_pool)})")
|
||||
except Exception as exc:
|
||||
debug(f"[get_tag] Failed to resolve numeric selection arg {token}: {exc}")
|
||||
# Helper to get field from both dict and object
|
||||
def get_field(obj: Any, field: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
@@ -1020,10 +1054,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return getattr(obj, field, default)
|
||||
|
||||
# Parse arguments using shared parser
|
||||
parsed_args = parse_cmdlet_args(args, CMDLET)
|
||||
parsed_args = parse_cmdlet_args(args_list, 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)
|
||||
scrape_flag_present = any(str(arg).lower() in {"-scrape", "--scrape"} for arg in args_list)
|
||||
|
||||
# Extract values
|
||||
hash_override_raw = parsed_args.get("hash")
|
||||
@@ -1033,10 +1067,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
scrape_url = parsed_args.get("scrape")
|
||||
scrape_requested = scrape_flag_present or scrape_url is not None
|
||||
|
||||
explicit_hash_flag = any(str(arg).lower() in {"-hash", "--hash"} for arg in raw_args)
|
||||
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
|
||||
debug(f"[get_tag] Ignoring invalid hash override '{hash_override_raw}' (explicit_flag={explicit_hash_flag})")
|
||||
if explicit_hash_flag:
|
||||
log("Invalid hash format: expected 64 hex characters", file=sys.stderr)
|
||||
return 1
|
||||
hash_override = None
|
||||
|
||||
if scrape_requested and (not scrape_url or str(scrape_url).strip() == ""):
|
||||
log("-scrape requires a URL or provider name", file=sys.stderr)
|
||||
@@ -1085,6 +1123,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
identifier_tags = [str(t) for t in tags_from_sidecar if isinstance(t, (str, bytes))]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
title_from_tags = _extract_tag_value(identifier_tags, "title")
|
||||
artist_from_tags = _extract_tag_value(identifier_tags, "artist")
|
||||
|
||||
identifiers = _extract_scrapable_identifiers(identifier_tags)
|
||||
identifier_query: Optional[str] = None
|
||||
@@ -1095,19 +1136,35 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
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)
|
||||
title_hint = title_from_tags or 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
|
||||
artist_hint = artist_from_tags or get_field(result, "artist", None) or get_field(result, "uploader", None)
|
||||
if not artist_hint:
|
||||
meta_field = get_field(result, "metadata", None)
|
||||
if isinstance(meta_field, dict):
|
||||
meta_artist = meta_field.get("artist") or meta_field.get("uploader")
|
||||
if meta_artist:
|
||||
artist_hint = str(meta_artist)
|
||||
|
||||
combined_query: Optional[str] = None
|
||||
if not identifier_query and title_hint and artist_hint and provider.name in {"itunes", "musicbrainz"}:
|
||||
if provider.name == "musicbrainz":
|
||||
combined_query = f'recording:"{title_hint}" AND artist:"{artist_hint}"'
|
||||
else:
|
||||
combined_query = f"{title_hint} {artist_hint}"
|
||||
|
||||
query_hint = identifier_query or title_hint
|
||||
query_hint = identifier_query or combined_query or title_hint
|
||||
if not query_hint:
|
||||
log("No title or identifier available to search for metadata", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if identifier_query:
|
||||
log(f"Using identifier for metadata search: {identifier_query}")
|
||||
elif combined_query:
|
||||
log(f"Using title+artist for metadata search: {title_hint} - {artist_hint}")
|
||||
else:
|
||||
log(f"Using title for metadata search: {query_hint}")
|
||||
|
||||
@@ -1319,6 +1376,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
_SCRAPE_CHOICES = []
|
||||
try:
|
||||
_SCRAPE_CHOICES = sorted(list_metadata_providers().keys())
|
||||
except Exception:
|
||||
_SCRAPE_CHOICES = ["itunes", "openlibrary", "googlebooks", "google", "musicbrainz"]
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="get-tag",
|
||||
summary="Get tags from Hydrus or local sidecar metadata",
|
||||
@@ -1341,8 +1405,9 @@ CMDLET = Cmdlet(
|
||||
CmdletArg(
|
||||
name="-scrape",
|
||||
type="string",
|
||||
description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
|
||||
required=False
|
||||
description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
|
||||
required=False,
|
||||
choices=_SCRAPE_CHOICES,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from fnmatch import fnmatchcase
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from collections import OrderedDict
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -135,6 +136,25 @@ class ResultItem:
|
||||
STORAGE_ORIGINS = {"local", "hydrus", "debrid"}
|
||||
|
||||
|
||||
def _normalize_extension(ext_value: Any) -> str:
|
||||
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
|
||||
ext = str(ext_value or "").strip().lstrip(".")
|
||||
|
||||
# Stop at common separators to avoid dragging status text into the extension
|
||||
for sep in (" ", "|", "(", "[", "{", ",", ";"):
|
||||
if sep in ext:
|
||||
ext = ext.split(sep, 1)[0]
|
||||
break
|
||||
|
||||
# If there are multiple dots, take the last token as the extension
|
||||
if "." in ext:
|
||||
ext = ext.split(".")[-1]
|
||||
|
||||
# Keep only alphanumeric characters and enforce max length
|
||||
ext = "".join(ch for ch in ext if ch.isalnum())
|
||||
return ext[:5]
|
||||
|
||||
|
||||
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()
|
||||
@@ -145,11 +165,11 @@ def _ensure_storage_columns(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
store_label = payload.get("origin") or payload.get("source") or origin_value
|
||||
|
||||
# Handle extension
|
||||
extension = payload.get("ext", "")
|
||||
extension = _normalize_extension(payload.get("ext", ""))
|
||||
if not extension and title:
|
||||
path_obj = Path(str(title))
|
||||
if path_obj.suffix:
|
||||
extension = path_obj.suffix.lstrip('.')
|
||||
extension = _normalize_extension(path_obj.suffix.lstrip('.'))
|
||||
title = path_obj.stem
|
||||
|
||||
# Handle size as integer MB (header will include units)
|
||||
@@ -175,7 +195,7 @@ def _ensure_storage_columns(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name="search-file",
|
||||
summary="Unified search cmdlet for storage (Hydrus, Local) and providers (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"),
|
||||
@@ -184,11 +204,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 (default: all searchable storages)"),
|
||||
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 storage (Hydrus, Local) and providers (Debrid, LibGen, OpenLibrary, 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",
|
||||
@@ -206,286 +226,306 @@ CMDLET = Cmdlet(
|
||||
|
||||
@register(["search-file", "search"])
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc."""
|
||||
args_list = [str(arg) for arg in (args or [])]
|
||||
|
||||
# Parse arguments
|
||||
query = ""
|
||||
tag_filters: List[str] = []
|
||||
size_filter: Optional[Tuple[str, int]] = None
|
||||
duration_filter: Optional[Tuple[str, float]] = None
|
||||
type_filter: Optional[str] = None
|
||||
storage_backend: Optional[str] = None
|
||||
provider_name: Optional[str] = None
|
||||
limit = 45
|
||||
searched_backends: List[str] = []
|
||||
|
||||
# Simple argument parsing
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
arg = args_list[i]
|
||||
low = arg.lower()
|
||||
|
||||
if low in {"-provider", "--provider"} and i + 1 < len(args_list):
|
||||
provider_name = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-storage", "--storage"} and i + 1 < len(args_list):
|
||||
storage_backend = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
|
||||
tag_filters.append(args_list[i + 1])
|
||||
i += 2
|
||||
elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
|
||||
try:
|
||||
limit = int(args_list[i + 1])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
i += 2
|
||||
elif low in {"-type", "--type"} and i + 1 < len(args_list):
|
||||
type_filter = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif not arg.startswith("-"):
|
||||
if query:
|
||||
query += " " + arg
|
||||
else:
|
||||
query = arg
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
"""Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc."""
|
||||
args_list = [str(arg) for arg in (args or [])]
|
||||
|
||||
# Parse arguments
|
||||
query = ""
|
||||
tag_filters: List[str] = []
|
||||
size_filter: Optional[Tuple[str, int]] = None
|
||||
duration_filter: Optional[Tuple[str, float]] = None
|
||||
type_filter: Optional[str] = None
|
||||
storage_backend: Optional[str] = None
|
||||
provider_name: Optional[str] = None
|
||||
limit = 45
|
||||
searched_backends: List[str] = []
|
||||
|
||||
# Simple argument parsing
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
arg = args_list[i]
|
||||
low = arg.lower()
|
||||
|
||||
if low in {"-provider", "--provider"} and i + 1 < len(args_list):
|
||||
provider_name = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-storage", "--storage"} and i + 1 < len(args_list):
|
||||
storage_backend = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
|
||||
tag_filters.append(args_list[i + 1])
|
||||
i += 2
|
||||
elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
|
||||
try:
|
||||
limit = int(args_list[i + 1])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
i += 2
|
||||
elif low in {"-type", "--type"} and i + 1 < len(args_list):
|
||||
type_filter = args_list[i + 1].lower()
|
||||
i += 2
|
||||
elif not arg.startswith("-"):
|
||||
if query:
|
||||
query += " " + arg
|
||||
else:
|
||||
query = arg
|
||||
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:
|
||||
# If result is a list, take the first item
|
||||
actual_result = result[0] if isinstance(result, list) and result else result
|
||||
|
||||
# Helper to get field
|
||||
def get_field(obj: Any, field: str) -> Any:
|
||||
return getattr(obj, field, None) or (obj.get(field) if isinstance(obj, dict) else None)
|
||||
|
||||
origin = get_field(actual_result, 'origin')
|
||||
target = get_field(actual_result, 'target')
|
||||
|
||||
# Special handling for Bandcamp artist/album drill-down
|
||||
if origin == 'bandcamp' and target:
|
||||
query = target
|
||||
if not provider_name:
|
||||
provider_name = 'bandcamp'
|
||||
|
||||
# Generic URL handling
|
||||
elif target and str(target).startswith(('http://', 'https://')):
|
||||
query = target
|
||||
# Try to infer provider from URL if not set
|
||||
if not provider_name:
|
||||
if 'bandcamp.com' in target:
|
||||
provider_name = 'bandcamp'
|
||||
elif 'youtube.com' in target or 'youtu.be' in target:
|
||||
provider_name = 'youtube'
|
||||
# Extract store: filter tokens (works with commas or whitespace) and clean query for backends
|
||||
store_filter: Optional[str] = None
|
||||
if query:
|
||||
match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
|
||||
if match:
|
||||
store_filter = match.group(1).strip().lower() or None
|
||||
# Remove any store: tokens so downstream backends see only the actual query
|
||||
query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
|
||||
query = re.sub(r"\s{2,}", " ", query)
|
||||
query = query.strip().strip(',')
|
||||
|
||||
if not query:
|
||||
log("Provide a search query", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Initialize worker for this search command
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from config import get_local_storage_path
|
||||
import uuid
|
||||
worker_id = str(uuid.uuid4())
|
||||
library_root = get_local_storage_path(config or {})
|
||||
if not library_root:
|
||||
log("No library root configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
db = None
|
||||
try:
|
||||
db = LocalLibraryDB(library_root)
|
||||
db.insert_worker(
|
||||
worker_id,
|
||||
"search",
|
||||
title=f"Search: {query}",
|
||||
description=f"Query: {query}",
|
||||
pipe=ctx.get_current_command_text()
|
||||
)
|
||||
|
||||
results_list = []
|
||||
import result_table
|
||||
import importlib
|
||||
importlib.reload(result_table)
|
||||
from result_table import ResultTable
|
||||
|
||||
# Create ResultTable for display
|
||||
table_title = f"Search: {query}"
|
||||
if provider_name:
|
||||
table_title += f" [{provider_name}]"
|
||||
elif storage_backend:
|
||||
table_title += f" [{storage_backend}]"
|
||||
|
||||
table = ResultTable(table_title)
|
||||
table.set_source_command("search-file", args_list)
|
||||
|
||||
# Try to search using provider (libgen, soulseek, debrid, openlibrary)
|
||||
if provider_name:
|
||||
debug(f"[search_file] Attempting provider search with: {provider_name}")
|
||||
provider = get_provider(provider_name, config)
|
||||
if not provider:
|
||||
log(f"Provider '{provider_name}' not available", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
|
||||
debug(f"[search_file] Provider loaded, calling search with query: {query}")
|
||||
search_result = provider.search(query, limit=limit)
|
||||
debug(f"[search_file] Provider search returned {len(search_result)} results")
|
||||
|
||||
for item in search_result:
|
||||
# Add to table
|
||||
table.add_result(item)
|
||||
|
||||
# Emit to pipeline
|
||||
item_dict = item.to_dict()
|
||||
results_list.append(item_dict)
|
||||
ctx.emit(item_dict)
|
||||
|
||||
# Set the result table in context for TUI/CLI display
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
|
||||
debug(f"[search_file] Emitted {len(results_list)} results")
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
# Otherwise search using storage backends (Hydrus, Local)
|
||||
from helper.file_storage import FileStorage
|
||||
storage = FileStorage(config=config or {})
|
||||
|
||||
backend_to_search = storage_backend or None
|
||||
if backend_to_search:
|
||||
# Check if requested backend is available
|
||||
if backend_to_search == "hydrus":
|
||||
from helper.hydrus import is_hydrus_available
|
||||
if not is_hydrus_available(config or {}):
|
||||
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')
|
||||
return 1
|
||||
results = storage[backend_to_search].search(query, limit=limit)
|
||||
else:
|
||||
# Search all searchable backends, but skip hydrus if unavailable
|
||||
from helper.hydrus import is_hydrus_available
|
||||
hydrus_available = is_hydrus_available(config or {})
|
||||
|
||||
all_results = []
|
||||
for backend_name in storage.list_searchable_backends():
|
||||
# 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:
|
||||
all_results.extend(backend_results)
|
||||
if len(all_results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
|
||||
results = all_results[:limit]
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
# If store: was provided without explicit -storage/-provider, prefer that backend
|
||||
if store_filter and not provider_name and not storage_backend:
|
||||
if store_filter in {"hydrus", "local", "debrid"}:
|
||||
storage_backend = store_filter
|
||||
|
||||
# Handle piped input (e.g. from @N selection) if query is empty
|
||||
if not query and result:
|
||||
# If result is a list, take the first item
|
||||
actual_result = result[0] if isinstance(result, list) and result else result
|
||||
|
||||
# Helper to get field
|
||||
def get_field(obj: Any, field: str) -> Any:
|
||||
return getattr(obj, field, None) or (obj.get(field) if isinstance(obj, dict) else None)
|
||||
|
||||
origin = get_field(actual_result, 'origin')
|
||||
target = get_field(actual_result, 'target')
|
||||
|
||||
# Special handling for Bandcamp artist/album drill-down
|
||||
if origin == 'bandcamp' and target:
|
||||
query = target
|
||||
if not provider_name:
|
||||
provider_name = 'bandcamp'
|
||||
|
||||
# Generic URL handling
|
||||
elif target and str(target).startswith(('http://', 'https://')):
|
||||
query = target
|
||||
# Try to infer provider from URL if not set
|
||||
if not provider_name:
|
||||
if 'bandcamp.com' in target:
|
||||
provider_name = 'bandcamp'
|
||||
elif 'youtube.com' in target or 'youtu.be' in target:
|
||||
provider_name = 'youtube'
|
||||
|
||||
def _format_storage_label(name: str) -> str:
|
||||
clean = str(name or "").strip()
|
||||
if not clean:
|
||||
return "Unknown"
|
||||
return clean.replace("_", " ").title()
|
||||
if not query:
|
||||
log("Provide a search query", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Initialize worker for this search command
|
||||
from helper.local_library import LocalLibraryDB
|
||||
from config import get_local_storage_path
|
||||
import uuid
|
||||
worker_id = str(uuid.uuid4())
|
||||
library_root = get_local_storage_path(config or {})
|
||||
if not library_root:
|
||||
log("No library root configured", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
db = None
|
||||
try:
|
||||
db = LocalLibraryDB(library_root)
|
||||
db.insert_worker(
|
||||
worker_id,
|
||||
"search",
|
||||
title=f"Search: {query}",
|
||||
description=f"Query: {query}",
|
||||
pipe=ctx.get_current_command_text()
|
||||
)
|
||||
|
||||
results_list = []
|
||||
import result_table
|
||||
import importlib
|
||||
importlib.reload(result_table)
|
||||
from result_table import ResultTable
|
||||
|
||||
# Create ResultTable for display
|
||||
table_title = f"Search: {query}"
|
||||
if provider_name:
|
||||
table_title += f" [{provider_name}]"
|
||||
elif storage_backend:
|
||||
table_title += f" [{storage_backend}]"
|
||||
|
||||
table = ResultTable(table_title)
|
||||
table.set_source_command("search-file", args_list)
|
||||
|
||||
# Try to search using provider (libgen, soulseek, debrid, openlibrary)
|
||||
if provider_name:
|
||||
debug(f"[search_file] Attempting provider search with: {provider_name}")
|
||||
provider = get_provider(provider_name, config)
|
||||
if not provider:
|
||||
log(f"Provider '{provider_name}' not available", file=sys.stderr)
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
return 1
|
||||
|
||||
debug(f"[search_file] Provider loaded, calling search with query: {query}")
|
||||
search_result = provider.search(query, limit=limit)
|
||||
debug(f"[search_file] Provider search returned {len(search_result)} results")
|
||||
|
||||
for item in search_result:
|
||||
# Add to table
|
||||
table.add_result(item)
|
||||
|
||||
# Emit to pipeline
|
||||
item_dict = item.to_dict()
|
||||
results_list.append(item_dict)
|
||||
ctx.emit(item_dict)
|
||||
|
||||
# Set the result table in context for TUI/CLI display
|
||||
ctx.set_last_result_table(table, results_list)
|
||||
|
||||
debug(f"[search_file] Emitted {len(results_list)} results")
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
# Otherwise search using storage backends (Hydrus, Local)
|
||||
from helper.file_storage import FileStorage
|
||||
storage = FileStorage(config=config or {})
|
||||
|
||||
backend_to_search = storage_backend or None
|
||||
if backend_to_search:
|
||||
# Check if requested backend is available
|
||||
if backend_to_search == "hydrus":
|
||||
from helper.hydrus import is_hydrus_available
|
||||
if not is_hydrus_available(config or {}):
|
||||
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')
|
||||
return 1
|
||||
results = storage[backend_to_search].search(query, limit=limit)
|
||||
else:
|
||||
# Search all searchable backends, but skip hydrus if unavailable
|
||||
from helper.hydrus import is_hydrus_available
|
||||
hydrus_available = is_hydrus_available(config or {})
|
||||
|
||||
all_results = []
|
||||
for backend_name in storage.list_searchable_backends():
|
||||
# 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:
|
||||
all_results.extend(backend_results)
|
||||
if len(all_results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
log(f"Backend {backend_name} search failed: {exc}", file=sys.stderr)
|
||||
results = all_results[:limit]
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
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:
|
||||
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)}
|
||||
def _format_storage_label(name: str) -> str:
|
||||
clean = str(name or "").strip()
|
||||
if not clean:
|
||||
return "Unknown"
|
||||
return clean.replace("_", " ").title()
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
else:
|
||||
log("No results found", file=sys.stderr)
|
||||
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
||||
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Search failed: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if db:
|
||||
try:
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
finally:
|
||||
# Always close the database connection
|
||||
if db:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
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:
|
||||
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)
|
||||
if store_filter:
|
||||
origin_val = str(item_dict.get("origin") or item_dict.get("source") or "").lower()
|
||||
if store_filter != origin_val:
|
||||
continue
|
||||
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)
|
||||
|
||||
# Write results to worker stdout
|
||||
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
|
||||
else:
|
||||
log("No results found", file=sys.stderr)
|
||||
db.append_worker_stdout(worker_id, json.dumps([], indent=2))
|
||||
|
||||
db.update_worker_status(worker_id, 'completed')
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
log(f"Search failed: {exc}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if db:
|
||||
try:
|
||||
db.update_worker_status(worker_id, 'error')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
finally:
|
||||
# Always close the database connection
|
||||
if db:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
Reference in New Issue
Block a user