This commit is contained in:
nose
2025-12-07 00:21:30 -08:00
parent f29709d951
commit 6b05dc5552
23 changed files with 2196 additions and 1133 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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')

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)
]
)

View File

@@ -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