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

143
CLI.py
View File

@@ -231,9 +231,25 @@ def _get_table_title_for_command(cmd_name: str, emitted_items: Optional[List[Any
'delete_file': 'Results', 'delete_file': 'Results',
'check-file-status': 'Status', 'check-file-status': 'Status',
'check_file_status': 'Status', 'check_file_status': 'Status',
'get-metadata': None,
'get_metadata': None,
} }
return title_map.get(cmd_name, 'Results') mapped = title_map.get(cmd_name, 'Results')
if mapped is not None:
return mapped
# For metadata, derive title from first item if available
if emitted_items:
first = emitted_items[0]
try:
if isinstance(first, dict) and first.get('title'):
return str(first.get('title'))
if hasattr(first, 'title') and getattr(first, 'title'):
return str(getattr(first, 'title'))
except Exception:
pass
return 'Results'
def _close_cli_worker_manager() -> None: def _close_cli_worker_manager() -> None:
@@ -409,9 +425,20 @@ def _get_cmdlet_names() -> List[str]:
def _import_cmd_module(mod_name: str): def _import_cmd_module(mod_name: str):
"""Import a cmdlet/native module from cmdlets or cmdnats packages.""" """Import a cmdlet/native module from cmdlets or cmdnats packages."""
for package in ("cmdlets", "cmdnats", None): # Normalize leading punctuation used in aliases (e.g., .pipe)
normalized = (mod_name or "").strip()
if normalized.startswith('.'):
normalized = normalized.lstrip('.')
# Convert hyphens to underscores to match module filenames
normalized = normalized.replace("-", "_")
if not normalized:
return None
# Prefer native cmdnats modules first so editable installs of this package
# don't shadow the in-repo implementations (e.g., .pipe autocomplete flags).
for package in ("cmdnats", "cmdlets", None):
try: try:
qualified = f"{package}.{mod_name}" if package else mod_name qualified = f"{package}.{normalized}" if package else normalized
return import_module(qualified) return import_module(qualified)
except ModuleNotFoundError: except ModuleNotFoundError:
continue continue
@@ -495,6 +522,15 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
merged = sorted(set(provider_choices + meta_choices)) merged = sorted(set(provider_choices + meta_choices))
if merged: if merged:
return merged return merged
if normalized_arg == "scrape":
try:
from helper.metadata_search import list_metadata_providers
meta_providers = list_metadata_providers(_load_cli_config())
if meta_providers:
return sorted(meta_providers.keys())
except Exception:
pass
mod = _import_cmd_module(mod_name) mod = _import_cmd_module(mod_name)
data = getattr(mod, "CMDLET", None) if mod else None data = getattr(mod, "CMDLET", None) if mod else None
if data: if data:
@@ -536,36 +572,48 @@ if (
text = document.text_before_cursor text = document.text_before_cursor
tokens = text.split() tokens = text.split()
if not tokens: # Respect pipeline stages: only use tokens after the last '|'
last_pipe = -1
for idx, tok in enumerate(tokens):
if tok == "|":
last_pipe = idx
stage_tokens = tokens[last_pipe + 1:] if last_pipe >= 0 else tokens
if not stage_tokens:
for cmd in self.cmdlet_names: for cmd in self.cmdlet_names:
yield CompletionType(cmd, start_position=0) yield CompletionType(cmd, start_position=0)
elif len(tokens) == 1: return
current = tokens[0].lower()
# Single token at this stage -> suggest command names/keywords
if len(stage_tokens) == 1:
current = stage_tokens[0].lower()
for cmd in self.cmdlet_names: for cmd in self.cmdlet_names:
if cmd.startswith(current): if cmd.startswith(current):
yield CompletionType(cmd, start_position=-len(current)) yield CompletionType(cmd, start_position=-len(current))
for keyword in ["help", "exit", "quit"]: for keyword in ["help", "exit", "quit"]:
if keyword.startswith(current): if keyword.startswith(current):
yield CompletionType(keyword, start_position=-len(current)) yield CompletionType(keyword, start_position=-len(current))
else: return
cmd_name = tokens[0].replace("_", "-").lower()
current_token = tokens[-1].lower()
prev_token = tokens[-2].lower() if len(tokens) > 1 else ""
choices = _get_arg_choices(cmd_name, prev_token) # Otherwise treat first token of stage as command and complete its args
if choices: cmd_name = stage_tokens[0].replace("_", "-").lower()
for choice in choices: current_token = stage_tokens[-1].lower()
if choice.lower().startswith(current_token): prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
yield CompletionType(choice, start_position=-len(current_token))
return
arg_names = _get_cmdlet_args(cmd_name) choices = _get_arg_choices(cmd_name, prev_token)
for arg in arg_names: if choices:
if arg.lower().startswith(current_token): for choice in choices:
yield CompletionType(arg, start_position=-len(current_token)) if choice.lower().startswith(current_token):
yield CompletionType(choice, start_position=-len(current_token))
return
if "--help".startswith(current_token): arg_names = _get_cmdlet_args(cmd_name)
yield CompletionType("--help", start_position=-len(current_token)) for arg in arg_names:
if arg.lower().startswith(current_token):
yield CompletionType(arg, start_position=-len(current_token))
if "--help".startswith(current_token):
yield CompletionType("--help", start_position=-len(current_token))
async def get_completions_async(self, document: Document, complete_event): # type: ignore[override] async def get_completions_async(self, document: Document, complete_event): # type: ignore[override]
for completion in self.get_completions(document, complete_event): for completion in self.get_completions(document, complete_event):
@@ -689,6 +737,7 @@ def _create_cmdlet_cli():
|246813579|JKLMNOPQR| |246813579|JKLMNOPQR|
|369369369|STUVWXYZ0| |369369369|STUVWXYZ0|
|483726159|ABCDEFGHI| |483726159|ABCDEFGHI|
|=========+=========|
|516273849|JKLMNOPQR| |516273849|JKLMNOPQR|
|639639639|STUVWXYZ0| |639639639|STUVWXYZ0|
|753186429|ABCDEFGHI| |753186429|ABCDEFGHI|
@@ -699,7 +748,7 @@ def _create_cmdlet_cli():
print(banner) print(banner)
# Configurable prompt # Configurable prompt
prompt_text = ">>>|" prompt_text = "🜂🜄🜁🜃|"
# Pre-acquire Hydrus session key at startup (like hub-ui does) # Pre-acquire Hydrus session key at startup (like hub-ui does)
try: try:
@@ -840,7 +889,6 @@ def _create_cmdlet_cli():
return input(prompt) return input(prompt)
while True: while True:
print("#-------------------------------------------------------------------------#")
try: try:
user_input = get_input(prompt_text).strip() user_input = get_input(prompt_text).strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
@@ -971,6 +1019,19 @@ def _execute_pipeline(tokens: list):
if not stages: if not stages:
print("Invalid pipeline syntax\n") print("Invalid pipeline syntax\n")
return return
# If a previous stage paused for selection, attach its remaining stages when the user runs only @N
pending_tail = ctx.get_pending_pipeline_tail() if hasattr(ctx, 'get_pending_pipeline_tail') else []
pending_source = ctx.get_pending_pipeline_source() if hasattr(ctx, 'get_pending_pipeline_source') else None
current_source = ctx.get_current_stage_table_source_command() if hasattr(ctx, 'get_current_stage_table_source_command') else None
selection_only = len(stages) == 1 and stages[0] and stages[0][0].startswith('@')
if pending_tail and selection_only:
if pending_source and current_source and current_source == pending_source:
stages.extend(pending_tail)
if hasattr(ctx, 'clear_pending_pipeline_tail'):
ctx.clear_pending_pipeline_tail()
elif hasattr(ctx, 'clear_pending_pipeline_tail'):
ctx.clear_pending_pipeline_tail()
# Load config relative to CLI root # Load config relative to CLI root
config = _load_cli_config() config = _load_cli_config()
@@ -1044,7 +1105,9 @@ def _execute_pipeline(tokens: list):
command_expanded = False command_expanded = False
selected_row_args = [] selected_row_args = []
if source_cmd: skip_pipe_expansion = source_cmd == '.pipe' and len(stages) > 0
if source_cmd and not skip_pipe_expansion:
# Try to find row args for the selected indices # Try to find row args for the selected indices
for idx in first_stage_selection_indices: for idx in first_stage_selection_indices:
row_args = ctx.get_current_stage_table_row_selection_args(idx) row_args = ctx.get_current_stage_table_row_selection_args(idx)
@@ -1151,6 +1214,9 @@ def _execute_pipeline(tokens: list):
if source_cmd == '.pipe' or source_cmd == '.adjective': if source_cmd == '.pipe' or source_cmd == '.adjective':
should_expand_to_command = True should_expand_to_command = True
if source_cmd == '.pipe' and (stage_index + 1 < len(stages) or stage_args):
# When piping playlist rows to another cmdlet, prefer item-based selection
should_expand_to_command = False
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args: elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Special case for youtube search results: @N expands to .pipe # Special case for youtube search results: @N expands to .pipe
if stage_index + 1 >= len(stages): if stage_index + 1 >= len(stages):
@@ -1170,6 +1236,10 @@ def _execute_pipeline(tokens: list):
# Single format object # Single format object
if source_cmd: if source_cmd:
should_expand_to_command = True should_expand_to_command = True
# If we have a source command but no piped data (paused for selection), expand to command
if not should_expand_to_command and source_cmd and selection is not None and piped_result is None:
should_expand_to_command = True
# If expanding to command, replace this stage and re-execute # If expanding to command, replace this stage and re-execute
if should_expand_to_command and selection is not None: if should_expand_to_command and selection is not None:
@@ -1360,6 +1430,27 @@ def _execute_pipeline(tokens: list):
# Intermediate stage - thread to next stage # Intermediate stage - thread to next stage
piped_result = pipeline_ctx.emits piped_result = pipeline_ctx.emits
ctx.set_last_result_table(None, pipeline_ctx.emits) ctx.set_last_result_table(None, pipeline_ctx.emits)
else:
# No output from this stage. If it presented a selectable table (e.g., format list), pause
# and stash the remaining pipeline so @N can resume with the selection applied.
if not is_last_stage:
stage_table_source = ctx.get_current_stage_table_source_command()
row_has_selection = ctx.get_current_stage_table_row_selection_args(0) is not None
if stage_table_source and row_has_selection:
pending_tail = stages[stage_index + 1:]
if pending_tail and pending_tail[0] and pending_tail[0][0].startswith('@'):
pending_tail = pending_tail[1:]
if hasattr(ctx, 'set_pending_pipeline_tail') and pending_tail:
ctx.set_pending_pipeline_tail(pending_tail, stage_table_source)
elif hasattr(ctx, 'clear_pending_pipeline_tail'):
ctx.clear_pending_pipeline_tail()
if pipeline_session and worker_manager:
try:
worker_manager.log_step(pipeline_session.worker_id, "Pipeline paused for @N selection")
except Exception:
pass
print("Pipeline paused: select a format with @N to continue remaining stages")
return
if ret_code != 0: if ret_code != 0:
stage_status = "failed" stage_status = "failed"

View File

@@ -11,12 +11,32 @@ local opts = {
} }
-- Detect CLI path -- Detect CLI path
local script_dir = mp.get_script_directory() local function detect_script_dir()
local dir = mp.get_script_directory()
if dir and dir ~= "" then return dir end
-- Fallback to debug info path
local src = debug.getinfo(1, "S").source
if src and src:sub(1, 1) == "@" then
local path = src:sub(2)
local parent = path:match("(.*)[/\\]")
if parent and parent ~= "" then
return parent
end
end
-- Fallback to working directory
local cwd = utils.getcwd()
if cwd and cwd ~= "" then return cwd end
return nil
end
local script_dir = detect_script_dir() or ""
if not opts.cli_path then if not opts.cli_path then
-- Assuming the structure is repo/LUA/script.lua and repo/CLI.py -- Assuming the structure is repo/LUA/script.lua and repo/CLI.py
-- We need to go up one level -- We need to go up one level
local parent_dir = script_dir:match("(.*)[/\\]") local parent_dir = script_dir:match("(.*)[/\\]")
if parent_dir then if parent_dir and parent_dir ~= "" then
opts.cli_path = parent_dir .. "/CLI.py" opts.cli_path = parent_dir .. "/CLI.py"
else else
opts.cli_path = "CLI.py" -- Fallback opts.cli_path = "CLI.py" -- Fallback

View File

@@ -32,10 +32,10 @@ python cli.py
``` ```
Adding your first file Adding your first file
```python ```python
.pipe -list # List MPV current playing/list .pipe -list # List MPV current playing/list
.pipe -save # Save playlist .pipe -save # Save current MPV playlist to local library
.pipe -load # lists saved playlist, @# to load .pipe -load # List saved playlists; use @N to load one
.pipe "https://www.youtube.com/watch?v=_23dFb50Z2Y" # adds to current playlist .pipe "https://www.youtube.com/watch?v=_23dFb50Z2Y" # Add URL to current playlist
``` ```
1. search-file -provider youtube "something in the way" 1. search-file -provider youtube "something in the way"

View File

@@ -1128,6 +1128,47 @@ def merge_sequences(*sources: Optional[Iterable[Any]], case_sensitive: bool = Tr
return merged 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]: def extract_tags_from_result(result: Any) -> list[str]:
tags: list[str] = [] tags: list[str] = []
if isinstance(result, models.PipeObject): 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, extract_tags_from_result, extract_title_from_result, extract_known_urls_from_result,
merge_sequences, extract_relationships, extract_duration 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.local_library import read_sidecar, find_sidecar, write_sidecar, LocalLibraryDB
from helper.utils import sha256_file from helper.utils import sha256_file
from metadata import embed_metadata_in_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 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( def _persist_local_metadata(
library_root: Path, library_root: Path,
dest_path: Path, dest_path: Path,
@@ -209,7 +235,7 @@ def _handle_local_transfer(
try: try:
destination_root.mkdir(parents=True, exist_ok=True) destination_root.mkdir(parents=True, exist_ok=True)
except Exception as exc: 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 return 1, None
@@ -234,8 +260,8 @@ def _handle_local_transfer(
return f"title:{value}" return f"title:{value}"
return tag return tag
tags_from_result = [normalize_title_tag(t) for t in tags_from_result] tags_from_result = collapse_namespace_tags([normalize_title_tag(t) for t in tags_from_result], "title", prefer="last")
sidecar_tags = [normalize_title_tag(t) for t in sidecar_tags] 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 # Merge tags carefully: if URL has title tag, don't include sidecar title tags
# This prevents duplicate title: tags when URL provides a title # This prevents duplicate title: tags when URL provides a title
@@ -295,6 +321,7 @@ def _handle_local_transfer(
else: else:
# Ensure filename is the hash when adding to local storage # Ensure filename is the hash when adding to local storage
resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path) resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path)
hashed_move_done = False
if resolved_hash: if resolved_hash:
hashed_name = resolved_hash + media_path.suffix hashed_name = resolved_hash + media_path.suffix
target_path = destination_root / hashed_name target_path = destination_root / hashed_name
@@ -305,7 +332,13 @@ def _handle_local_transfer(
pass pass
if media_path != target_path: if media_path != target_path:
media_path = media_path.rename(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: except Exception as exc:
log(f"❌ Failed to move file into {destination_root}: {exc}", file=sys.stderr) log(f"❌ Failed to move file into {destination_root}: {exc}", file=sys.stderr)
return 1, None return 1, None
@@ -316,7 +349,7 @@ def _handle_local_transfer(
# If we have a title tag, keep it. Otherwise, derive from filename. # 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) 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: if not has_title:
filename_title = dest_path.stem.replace("_", " ").strip() filename_title = dest_path.stem.replace("_", " ").strip()
@@ -326,7 +359,7 @@ def _handle_local_transfer(
if not export_mode: if not export_mode:
_persist_local_metadata(destination_root, dest_path, final_tags, merged_urls, file_hash, relationships, duration, media_kind) _persist_local_metadata(destination_root, dest_path, final_tags, merged_urls, file_hash, relationships, duration, media_kind)
_cleanup_sidecar_files(media_path, sidecar_path) _cleanup_sidecar_files(media_path, sidecar_path)
debug(f"✅ Moved to local library: {dest_path}") _show_local_result_table(file_hash, config or {})
else: else:
debug(f"✅ Exported to destination: {dest_path}") debug(f"✅ Exported to destination: {dest_path}")
return 0, 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) location = str(path_value)
# Get location from parsed args - now uses SharedArgs.STORAGE so key is "storage" # Get location from parsed args - now uses SharedArgs.STORAGE so key is "storage"
location = parsed.get("storage") storage_arg = parsed.get("storage")
if location: if location is None:
location = str(location).lower().strip() 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 # Get file provider from parsed args
provider_name = parsed.get("provider") 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: except OSError as exc:
log(f"Failed to delete sidecar: {exc}", file=sys.stderr) 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 # Emit result for Hydrus uploads so downstream commands know about it
if location == 'hydrus': if location == 'hydrus':
# Extract title from original result, fallback to filename if not available # 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 # 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 # Next stage will use these Hydrus file results, not format objects
ctx.set_current_stage_table(None) 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 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 import hydrus as hydrus_wrapper
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, has_sidecar, LocalLibraryDB from helper.local_library import read_sidecar, write_sidecar, find_sidecar, has_sidecar, LocalLibraryDB
from metadata import rename 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 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 target_hash = hydrus_hash or file_hash
refresh_args: List[str] = [] refresh_args: List[str] = []
if target_hash: if target_hash:
refresh_args = ["-hash", target_hash] refresh_args = ["-hash", target_hash, "-store", target_hash]
try: try:
subject = ctx.get_last_result_subject() 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: if new_tag not in existing_tags:
existing_tags.append(new_tag) 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 # 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] 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) 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 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: 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: for item in items:
if _process_single_item(item, override_hash, conserve, lib_root, reason, config): if _process_single_item(item, override_hash, conserve, lib_root, reason, config):
success_count += 1 success_count += 1
if success_count > 0:
_refresh_last_search(config)
return 0 if success_count > 0 else 1 return 0 if success_count > 0 else 1
CMDLET = Cmdlet( 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 # Create result table for format display
table = ResultTable(title=f"Available Formats - {probe_info.get('title', 'Unknown')}") 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 = table.add_row()
row.add_column("Format ID", fmt.get("format_id", "")) 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"): if fmt.get("filesize"):
size_mb = fmt["filesize"] / (1024 * 1024) size_mb = fmt["filesize"] / (1024 * 1024)
row.add_column("Size", f"{size_mb:.1f} MB") 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 # Set source command for @N expansion
table.set_source_command("download-data", [url]) table.set_source_command("download-data", [url])
# Note: Row selection args are not set - users select with @N syntax directly # Display table
# Display table and emit as pipeline result
log(str(table), flush=True) log(str(table), flush=True)
formats_displayed = True formats_displayed = True
# Store table for @N expansion so CLI can reconstruct commands # 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) pipeline_context.set_current_stage_table(table)
pipeline_context.set_last_result_table_overlay(table, formats)
# Always emit formats so they can be selected with @N debug("Use @N to pick a format; pipeline paused until 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", ""),
"ext": fmt.get("ext", ""),
"filesize": fmt.get("filesize"),
"source_url": url,
"index": i,
})
debug(f"Use @N syntax to select a format and download")
else: else:
log(f"✗ No formats available for this URL", file=sys.stderr) 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 ====== # ====== AUTO-DETECT MULTIPLE FORMATS ======
# Check if multiple formats exist and handle based on -item flag # 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 # Set source command for @N expansion
table.set_source_command("download-data", [url]) table.set_source_command("download-data", [url])
# Set row selection args so @N expands to "download-data URL -item N" # Set row selection args so @N expands to "download-data URL -item N"
for i in range(len(formats)): 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)]) table.set_row_selection_args(i, ["-item", str(i + 1)])
# Display table and emit formats so they can be selected with @N # Display table
debug(str(table)) log(str(table), flush=True)
debug(f"💡 Use @N syntax to select a format and download (e.g., @1)") debug(f"💡 Use @N syntax to select a format and download (e.g., @1)")
# Store table for @N expansion so CLI can reconstruct commands # Store table for @N expansion so CLI can reconstruct commands
pipeline_context.set_current_stage_table(table) pipeline_context.set_current_stage_table(table)
pipeline_context.set_last_result_table_overlay(table, formats)
# 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,
})
formats_displayed = True # Mark that we displayed 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}") 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: if downloaded_files or files_downloaded_directly > 0:
total_files = len(downloaded_files) + files_downloaded_directly total_files = len(downloaded_files) + files_downloaded_directly
debug(f"✓ Successfully downloaded {total_files} file(s)") debug(f"✓ Successfully downloaded {total_files} file(s)")
# Create a result table for the downloaded files stage_ctx = pipeline_context.get_stage_context()
# This ensures that subsequent @N commands select from these files should_display_results = stage_ctx is None or stage_ctx.is_last_stage
# instead of trying to expand the previous command (e.g. search-file)
if downloaded_files: if downloaded_files and should_display_results:
from result_table import ResultTable try:
table = ResultTable("Downloaded Files") from cmdlets import search_file as search_cmdlet
for i, file_path in enumerate(downloaded_files): except Exception:
# Ensure file_path is a Path object search_cmdlet = None
if isinstance(file_path, str):
file_path = Path(file_path) if search_cmdlet:
seen_hashes: set[str] = set()
row = table.add_row() for file_entry in downloaded_files:
row.add_column("#", str(i + 1)) path_obj = Path(file_entry) if not isinstance(file_entry, Path) else file_entry
row.add_column("File", file_path.name) if not path_obj.is_file():
row.add_column("Path", str(file_path)) continue
try: file_hash = _compute_file_hash(path_obj)
size_mb = file_path.stat().st_size / (1024*1024) if file_hash and file_hash not in seen_hashes:
row.add_column("Size", f"{size_mb:.1f} MB") seen_hashes.add(file_hash)
except OSError: search_cmdlet._run(None, [f"hash:{file_hash}"], config)
row.add_column("Size", "?") else:
debug("search-file not available; skipping post-download display")
# Set selection args to just the file path (or index if we want item selection) elif downloaded_files:
# For item selection fallback, we don't strictly need row args if source command is None debug("Skipping search-file display because downstream pipeline is present")
# 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)
if db: if db:
db.update_worker_status(worker_id, 'completed') db.update_worker_status(worker_id, 'completed')

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Optional, Sequence from typing import Any, Callable, Dict, List, Optional, Sequence
from pathlib import Path from pathlib import Path
import shutil as _shutil import shutil as _shutil
import subprocess as _subprocess import subprocess as _subprocess
@@ -8,13 +8,15 @@ import json
import sys import sys
import platform import platform
import threading
from helper.logger import log, debug from helper.logger import log, debug
import uuid as _uuid import uuid as _uuid
import time as _time import time as _time
from helper.progress import print_progress, print_final_progress from helper.progress import print_progress, print_final_progress
from helper.http_client import HTTPClient 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 import fnmatch as _fnmatch
from . import register 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 config import resolve_output_dir, get_hydrus_url, get_hydrus_access_key
from helper.alldebrid import AllDebridClient 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) log("✗ No valid magnet IDs in pipe", file=sys.stderr)
return 1 return 1
# Get API key return _queue_alldebrid_worker(
from config import get_debrid_api_key config=config,
api_key = get_debrid_api_key(config) output_dir=out_path,
if not api_key: magnet_ids=magnets,
log("AllDebrid API key not configured", file=sys.stderr) title=f"AllDebrid pipe ({len(magnets)} magnet{'s' if len(magnets) != 1 else ''})",
return 1 file_filter=file_filter,
)
# 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
def _extract_files_from_magnet(magnet_info: Dict[str, Any], filter_pattern: Optional[str] = None) -> list: 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 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: 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.""" """Check if file can be played in MPV based on extension or mime type."""
from helper.utils_constant import mime_maps 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() ipc_pipe = get_ipc_pipe_path()
debug(f"[get-file] Starting new MPV instance (pipe: {ipc_pipe})", file=sys.stderr) 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}'] 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: if headers:
# Format headers for command line # 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): elif force_mpv or (is_media and mpv_available):
# Auto-play in MPV for media files (if available), or user requested it # 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): if _play_in_mpv(stream_url, file_title, is_stream=True, headers=headers):
# Show pipe menu instead of emitting result for display # Show unified MPV playlist view (reuse cmdnats.pipe display)
# This allows immediate @N selection from the playlist try:
from . import pipe from cmdnats import pipe as mpv_pipe
pipe._run(None, [], config) mpv_pipe._run(None, [], config)
except Exception:
pass
return 0 return 0
else: else:
# Fall back to browser # 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): elif force_mpv or (is_media and mpv_available):
# Auto-play in MPV for media files (if available), or user requested it # Auto-play in MPV for media files (if available), or user requested it
if _play_in_mpv(file_path, file_title, is_stream=False): if _play_in_mpv(file_path, file_title, is_stream=False):
# Show pipe menu instead of emitting result for display # Show unified MPV playlist view (reuse cmdnats.pipe display)
# This allows immediate @N selection from the playlist try:
from . import pipe from cmdnats import pipe as mpv_pipe
pipe._run(None, [], config) mpv_pipe._run(None, [], config)
except Exception:
pass
return 0 return 0
else: else:
# Fall back to default application # 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) log(f"✗ Error creating output directory: {e}", file=sys.stderr)
return 1 return 1
# Get API key return _queue_alldebrid_worker(
from config import get_debrid_api_key config=config,
api_key = get_debrid_api_key(config) output_dir=out_path,
if not api_key: magnet_ids=[magnet_id],
log("✗ AllDebrid API key not configured in config.json", file=sys.stderr) title=magnet_title or f"magnet {magnet_id}",
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
@register(["get-file"]) # primary name @register(["get-file"]) # primary name
@@ -1043,7 +1114,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
else: else:
base_name = 'export' 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://')) is_url = isinstance(local_target, str) and local_target.startswith(('http://', 'https://'))
# Establish file hash (prefer -hash override when provided and valid) # Establish file hash (prefer -hash override when provided and valid)
if hash_spec and looks_like_hash(hash_spec): 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: if not force_local:
# Default: Stream to MPV # Default: Stream to MPV
if _play_in_mpv(url, title, is_stream=True): if _play_in_mpv(url, title, is_stream=True):
from . import pipe try:
pipe._run(None, [], config) from cmdnats import pipe as mpv_pipe
return 0 mpv_pipe._run(None, [], config)
except Exception:
pass
return 0
else: else:
# Fallback to browser # Fallback to browser
try: try:
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
debug(f"[get-file] Opened in browser: {title}", file=sys.stderr) debug(f"[get-file] Opened in browser: {title}", file=sys.stderr)
return 0 return 0
except Exception: except Exception:
pass pass
return 1 return 1
# Download mode # Download mode
try: try:

View File

@@ -10,7 +10,85 @@ import mimetypes
import os import os
from helper import hydrus as hydrus_wrapper from helper import hydrus as hydrus_wrapper
from helper.local_library import LocalLibraryDB
from ._shared import Cmdlet, CmdletArg, normalize_hash 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: 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: if not mime_type:
mime_type = "unknown" mime_type = "unknown"
# Get file size # Pull metadata from local DB if available (for imported timestamp, duration, etc.)
try: db_metadata = None
file_size = file_path.stat().st_size library_root = get_local_storage_path(config)
except Exception: if library_root:
file_size = None try:
with LocalLibraryDB(library_root) as db:
# Try to get duration if it's a media file 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 duration_seconds = None
try: pages = None
# Try to use ffprobe if available if isinstance(db_metadata, dict):
import subprocess if isinstance(db_metadata.get("duration"), (int, float)):
result_proc = subprocess.run( duration_seconds = float(db_metadata.get("duration"))
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path)], if isinstance(db_metadata.get("pages"), (int, float)):
capture_output=True, pages = int(db_metadata.get("pages"))
text=True,
timeout=5 if duration_seconds is None and mime_type and mime_type.startswith("video"):
) try:
if result_proc.returncode == 0 and result_proc.stdout.strip(): import subprocess
try: 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()) duration_seconds = float(result_proc.stdout.strip())
except ValueError: except Exception:
pass pass
except Exception:
pass # Known URLs from sidecar or result
# 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
urls = [] urls = []
sidecar_path = Path(str(file_path) + '.tags') sidecar_path = Path(str(file_path) + '.tags')
if sidecar_path.exists(): if sidecar_path.exists():
@@ -119,30 +204,45 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
urls.append(url_value) urls.append(url_value)
except Exception: except Exception:
pass pass
# Fallback to result URLs if not in sidecar
if not urls: if not urls:
urls_from_result = get_field(result, "known_urls", None) or get_field(result, "urls", None) urls_from_result = get_field(result, "known_urls", None) or get_field(result, "urls", None)
if isinstance(urls_from_result, list): if isinstance(urls_from_result, list):
urls.extend([str(u).strip() for u in urls_from_result if u]) urls.extend([str(u).strip() for u in urls_from_result if u])
# Display local file metadata imported_ts = None
log(f"PATH: {file_path}") if isinstance(db_metadata, dict):
if hash_hex: ts = db_metadata.get("time_imported") or db_metadata.get("time_added")
log(f"HASH: {hash_hex}") if isinstance(ts, (int, float)):
if mime_type: imported_ts = int(ts)
log(f"MIME: {mime_type}") elif isinstance(ts, str):
if size_label: try:
log(f"Size: {size_label}") import datetime as _dt
if dur_label: imported_ts = int(_dt.datetime.fromisoformat(ts).timestamp())
log(f"Duration: {dur_label}") except Exception:
if urls: imported_ts = None
log("URLs:")
for url in urls: row = _build_table_row(
log(f" {url}") 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 return 0
except Exception as exc: except Exception:
# Fall through to Hydrus if local file handling fails # Fall through to Hydrus if local file handling fails
pass 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 inner = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else None
if duration_value is None and isinstance(inner, dict): if duration_value is None and isinstance(inner, dict):
duration_value = inner.get("duration") duration_value = inner.get("duration")
imported_ts = _extract_imported_ts(meta)
try: 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 from .search_file import _hydrus_duration_seconds as _dur_secs
except Exception: except Exception:
_fmt_size = lambda x: str(x) if x is not None else ""
_dur_secs = lambda x: x _dur_secs = lambda x: x
_fmt_dur = lambda x: str(x) if x is not None else ""
dur_seconds = _dur_secs(duration_value) 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") urls = meta.get("known_urls") or meta.get("urls")
if isinstance(urls, list) and urls: urls = [str(u).strip() for u in urls] if isinstance(urls, list) else []
log("URLs:")
for url in urls: row = _build_table_row(
try: title=hash_hex,
text = str(url).strip() origin="hydrus",
except Exception: path=f"hydrus://file/{hash_hex}",
text = "" mime=mime or "",
if text: size_bytes=int(size) if isinstance(size, int) else None,
log(f" {text}") 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 return 0

View File

@@ -57,6 +57,15 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
# Initialize results collection # Initialize results collection
found_relationships = [] # List of dicts: {hash, type, title, path, origin} found_relationships = [] # List of dicts: {hash, type, title, path, origin}
source_title = "Unknown" 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 # Check for local file first
file_path = None file_path = None
@@ -116,9 +125,10 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception: except Exception:
title = resolved_path.stem title = resolved_path.stem
found_relationships.append({ entry_type = "king" if rel_type.lower() == "alt" else rel_type
_add_relationship({
"hash": h, "hash": h,
"type": rel_type, "type": entry_type,
"title": title, "title": title,
"path": path, "path": path,
"origin": "local" "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) print(f"[DEBUG] Parent path obj: {parent_path_obj}", file=sys.stderr)
# Also add the king/parent itself if not already in results # 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 parent_title = parent_path_obj.stem
try: try:
parent_tags = db.get_tags(parent_path_obj) 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 pass
print(f"[DEBUG] Adding king/parent to results: {parent_title}", file=sys.stderr) print(f"[DEBUG] Adding king/parent to results: {parent_title}", file=sys.stderr)
found_relationships.append({ _add_relationship({
"hash": str(path), "hash": str(path),
"type": "king" if rel_type.lower() == "alt" else rel_type, "type": "king" if rel_type.lower() == "alt" else rel_type,
"title": parent_title, "title": parent_title,
@@ -157,11 +172,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
}) })
else: else:
# If already in results, ensure it's marked as king if appropriate # If already in results, ensure it's marked as king if appropriate
for r in found_relationships: if rel_type.lower() == "alt":
if str(r['hash']).lower() == str(path).lower(): existing_parent['type'] = "king"
if rel_type.lower() == "alt":
r['type'] = "king"
break
# 1. Check forward relationships from parent (siblings) # 1. Check forward relationships from parent (siblings)
parent_metadata = db.get_metadata(parent_path_obj) 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) print(f"[DEBUG] ⏭️ Hash doesn't resolve, skipping: {child_h}", file=sys.stderr)
continue continue
# Skip the current file we're querying # Check if already added (case-insensitive hash/path check)
if str(child_path_obj).lower() == str(path_obj).lower(): 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] ⏭️ 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):
print(f"[DEBUG] ⏭️ Already in results: {child_h}", file=sys.stderr) print(f"[DEBUG] ⏭️ Already in results: {child_h}", file=sys.stderr)
continue continue
@@ -207,7 +214,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
pass pass
print(f"[DEBUG] Adding sibling: {child_title}", file=sys.stderr) print(f"[DEBUG] Adding sibling: {child_title}", file=sys.stderr)
found_relationships.append({ _add_relationship({
"hash": child_h, "hash": child_h,
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})", "type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
"title": child_title, "title": child_title,
@@ -226,13 +233,8 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
child_type = child['type'] child_type = child['type']
print(f"[DEBUG] Reverse child: {child_path}, type: {child_type}", file=sys.stderr) print(f"[DEBUG] Reverse child: {child_path}, type: {child_type}", file=sys.stderr)
# Skip the current file # Skip if already added (check by path/hash, case-insensitive)
if str(child_path).lower() == str(path_obj).lower(): 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] ⏭️ 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):
print(f"[DEBUG] ⏭️ Already in results: {child_path}", file=sys.stderr) print(f"[DEBUG] ⏭️ Already in results: {child_path}", file=sys.stderr)
continue continue
@@ -248,7 +250,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
pass pass
print(f"[DEBUG] Adding reverse sibling: {child_title}", file=sys.stderr) print(f"[DEBUG] Adding reverse sibling: {child_title}", file=sys.stderr)
found_relationships.append({ _add_relationship({
"hash": child_path, "hash": child_path,
"type": f"alt" if child_type == "alt" else f"sibling ({child_type})", "type": f"alt" if child_type == "alt" else f"sibling ({child_type})",
"title": child_title, "title": child_title,

View File

@@ -12,8 +12,8 @@ from __future__ import annotations
import sys import sys
from helper.logger import log from helper.logger import log, debug
from helper.metadata_search import get_metadata_provider from helper.metadata_search import get_metadata_provider, list_metadata_providers
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple 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 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]]]: 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. """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) --emit: Emit result without interactive prompt (quiet mode)
-scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks) -scrape <url|provider>: Scrape metadata from URL or provider name (itunes, openlibrary, googlebooks)
""" """
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 # Helper to get field from both dict and object
def get_field(obj: Any, field: str, default: Any = None) -> Any: def get_field(obj: Any, field: str, default: Any = None) -> Any:
if isinstance(obj, dict): 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) return getattr(obj, field, default)
# Parse arguments using shared parser # 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) # 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 # Extract values
hash_override_raw = parsed_args.get("hash") 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_url = parsed_args.get("scrape")
scrape_requested = scrape_flag_present or scrape_url is not None 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 hash_override_raw is not None:
if not hash_override or not looks_like_hash(hash_override): if not hash_override or not looks_like_hash(hash_override):
log("Invalid hash format: expected 64 hex characters", file=sys.stderr) debug(f"[get_tag] Ignoring invalid hash override '{hash_override_raw}' (explicit_flag={explicit_hash_flag})")
return 1 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() == ""): if scrape_requested and (not scrape_url or str(scrape_url).strip() == ""):
log("-scrape requires a URL or provider name", file=sys.stderr) 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))] identifier_tags = [str(t) for t in tags_from_sidecar if isinstance(t, (str, bytes))]
except Exception: except Exception:
pass 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) identifiers = _extract_scrapable_identifiers(identifier_tags)
identifier_query: Optional[str] = None 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") identifier_query = identifiers.get("musicbrainz") or identifiers.get("musicbrainzalbum")
# Determine query from identifier first, else title on the result or filename # 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: if not title_hint:
file_path = get_field(result, "path", None) or get_field(result, "filename", None) file_path = get_field(result, "path", None) or get_field(result, "filename", None)
if file_path: if file_path:
title_hint = Path(str(file_path)).stem 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: if not query_hint:
log("No title or identifier available to search for metadata", file=sys.stderr) log("No title or identifier available to search for metadata", file=sys.stderr)
return 1 return 1
if identifier_query: if identifier_query:
log(f"Using identifier for metadata search: {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: else:
log(f"Using title for metadata search: {query_hint}") 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 return 0
_SCRAPE_CHOICES = []
try:
_SCRAPE_CHOICES = sorted(list_metadata_providers().keys())
except Exception:
_SCRAPE_CHOICES = ["itunes", "openlibrary", "googlebooks", "google", "musicbrainz"]
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="get-tag", name="get-tag",
summary="Get tags from Hydrus or local sidecar metadata", summary="Get tags from Hydrus or local sidecar metadata",
@@ -1341,8 +1405,9 @@ CMDLET = Cmdlet(
CmdletArg( CmdletArg(
name="-scrape", name="-scrape",
type="string", type="string",
description="Scrape metadata from URL or provider name (returns tags as JSON or table)", description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
required=False required=False,
choices=_SCRAPE_CHOICES,
) )
] ]
) )

View File

@@ -6,6 +6,7 @@ from fnmatch import fnmatchcase
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, field from dataclasses import dataclass, field
from collections import OrderedDict from collections import OrderedDict
import re
import json import json
import os import os
import sys import sys
@@ -135,6 +136,25 @@ class ResultItem:
STORAGE_ORIGINS = {"local", "hydrus", "debrid"} 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]: def _ensure_storage_columns(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Attach Title/Store columns for storage-origin results to keep CLI display compact.""" """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() 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 store_label = payload.get("origin") or payload.get("source") or origin_value
# Handle extension # Handle extension
extension = payload.get("ext", "") extension = _normalize_extension(payload.get("ext", ""))
if not extension and title: if not extension and title:
path_obj = Path(str(title)) path_obj = Path(str(title))
if path_obj.suffix: if path_obj.suffix:
extension = path_obj.suffix.lstrip('.') extension = _normalize_extension(path_obj.suffix.lstrip('.'))
title = path_obj.stem title = path_obj.stem
# Handle size as integer MB (header will include units) # 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( CMDLET = Cmdlet(
name="search-file", 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]", usage="search-file [query] [-tag TAG] [-size >100MB|<50MB] [-type audio|video|image] [-duration >10:00] [-storage BACKEND] [-provider PROVIDER]",
args=[ args=[
CmdletArg("query", description="Search query string"), CmdletArg("query", description="Search query string"),
@@ -184,11 +204,11 @@ CMDLET = Cmdlet(
CmdletArg("type", description="Filter by type: audio, video, image, document"), CmdletArg("type", description="Filter by type: audio, video, image, document"),
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"), CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
CmdletArg("limit", type="integer", description="Limit results (default: 45)"), CmdletArg("limit", type="integer", description="Limit results (default: 45)"),
CmdletArg("storage", description="Search storage backend: hydrus, local (default: all searchable storages)"), 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("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
], ],
details=[ 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", "Use -provider to search a specific source, or -storage to search file backends",
"Filter results by: tag, size, type, duration", "Filter results by: tag, size, type, duration",
"Results can be piped to other commands", "Results can be piped to other commands",
@@ -206,286 +226,306 @@ CMDLET = Cmdlet(
@register(["search-file", "search"]) @register(["search-file", "search"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc.""" """Search across multiple providers: Hydrus, Local, Debrid, LibGen, etc."""
args_list = [str(arg) for arg in (args or [])] args_list = [str(arg) for arg in (args or [])]
# Parse arguments # Parse arguments
query = "" query = ""
tag_filters: List[str] = [] tag_filters: List[str] = []
size_filter: Optional[Tuple[str, int]] = None size_filter: Optional[Tuple[str, int]] = None
duration_filter: Optional[Tuple[str, float]] = None duration_filter: Optional[Tuple[str, float]] = None
type_filter: Optional[str] = None type_filter: Optional[str] = None
storage_backend: Optional[str] = None storage_backend: Optional[str] = None
provider_name: Optional[str] = None provider_name: Optional[str] = None
limit = 45 limit = 45
searched_backends: List[str] = [] searched_backends: List[str] = []
# Simple argument parsing # Simple argument parsing
i = 0 i = 0
while i < len(args_list): while i < len(args_list):
arg = args_list[i] arg = args_list[i]
low = arg.lower() low = arg.lower()
if low in {"-provider", "--provider"} and i + 1 < len(args_list): if low in {"-provider", "--provider"} and i + 1 < len(args_list):
provider_name = args_list[i + 1].lower() provider_name = args_list[i + 1].lower()
i += 2 i += 2
elif low in {"-storage", "--storage"} and i + 1 < len(args_list): elif low in {"-storage", "--storage"} and i + 1 < len(args_list):
storage_backend = args_list[i + 1].lower() storage_backend = args_list[i + 1].lower()
i += 2 i += 2
elif low in {"-tag", "--tag"} and i + 1 < len(args_list): elif low in {"-tag", "--tag"} and i + 1 < len(args_list):
tag_filters.append(args_list[i + 1]) tag_filters.append(args_list[i + 1])
i += 2 i += 2
elif low in {"-limit", "--limit"} and i + 1 < len(args_list): elif low in {"-limit", "--limit"} and i + 1 < len(args_list):
try: try:
limit = int(args_list[i + 1]) limit = int(args_list[i + 1])
except ValueError: except ValueError:
limit = 100 limit = 100
i += 2 i += 2
elif low in {"-type", "--type"} and i + 1 < len(args_list): elif low in {"-type", "--type"} and i + 1 < len(args_list):
type_filter = args_list[i + 1].lower() type_filter = args_list[i + 1].lower()
i += 2 i += 2
elif not arg.startswith("-"): elif not arg.startswith("-"):
if query: if query:
query += " " + arg query += " " + arg
else: else:
query = arg query = arg
i += 1 i += 1
else: else:
i += 1 i += 1
# Debrid is provider-only now # Extract store: filter tokens (works with commas or whitespace) and clean query for backends
if storage_backend and storage_backend.lower() == "debrid": store_filter: Optional[str] = None
log("Use -provider debrid instead of -storage debrid (debrid is provider-only)", file=sys.stderr) if query:
return 1 match = re.search(r"\bstore:([^\s,]+)", query, flags=re.IGNORECASE)
if match:
# Handle piped input (e.g. from @N selection) if query is empty store_filter = match.group(1).strip().lower() or None
if not query and result: # Remove any store: tokens so downstream backends see only the actual query
# If result is a list, take the first item query = re.sub(r"\s*[,]?\s*store:[^\s,]+", " ", query, flags=re.IGNORECASE)
actual_result = result[0] if isinstance(result, list) and result else result query = re.sub(r"\s{2,}", " ", query)
query = query.strip().strip(',')
# 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'
if not query: # Debrid is provider-only now
log("Provide a search query", file=sys.stderr) if storage_backend and storage_backend.lower() == "debrid":
return 1 log("Use -provider debrid instead of -storage debrid (debrid is provider-only)", 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]
# Also query Debrid provider by default (provider-only, but keep legacy coverage when no explicit provider given) # If store: was provided without explicit -storage/-provider, prefer that backend
if not provider_name and not storage_backend: if store_filter and not provider_name and not storage_backend:
try: if store_filter in {"hydrus", "local", "debrid"}:
debrid_provider = get_provider("debrid", config) storage_backend = store_filter
if debrid_provider and debrid_provider.validate():
remaining = max(0, limit - len(results)) if isinstance(results, list) else limit # Handle piped input (e.g. from @N selection) if query is empty
if remaining > 0: if not query and result:
debrid_results = debrid_provider.search(query, limit=remaining) # If result is a list, take the first item
if debrid_results: actual_result = result[0] if isinstance(result, list) and result else result
if "debrid" not in searched_backends:
searched_backends.append("debrid") # Helper to get field
if results is None: def get_field(obj: Any, field: str) -> Any:
results = [] return getattr(obj, field, None) or (obj.get(field) if isinstance(obj, dict) else None)
results.extend(debrid_results)
except Exception as exc: origin = get_field(actual_result, 'origin')
log(f"Debrid provider search failed: {exc}", file=sys.stderr) 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: if not query:
clean = str(name or "").strip() log("Provide a search query", file=sys.stderr)
if not clean: return 1
return "Unknown"
return clean.replace("_", " ").title() # 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) # Also query Debrid provider by default (provider-only, but keep legacy coverage when no explicit provider given)
for item in results or []: if not provider_name and not storage_backend:
origin = getattr(item, 'origin', None) try:
if origin is None and isinstance(item, dict): debrid_provider = get_provider("debrid", config)
origin = item.get('origin') or item.get('source') if debrid_provider and debrid_provider.validate():
if not origin: remaining = max(0, limit - len(results)) if isinstance(results, list) else limit
continue if remaining > 0:
key = str(origin).lower() debrid_results = debrid_provider.search(query, limit=remaining)
if key not in storage_counts: if debrid_results:
storage_counts[key] = 0 if "debrid" not in searched_backends:
storage_counts[key] += 1 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: def _format_storage_label(name: str) -> str:
display_counts = OrderedDict((_format_storage_label(name), count) for name, count in storage_counts.items()) clean = str(name or "").strip()
summary_line = table.set_storage_summary(display_counts, query, inline=True) if not clean:
if summary_line: return "Unknown"
table.title = summary_line return clean.replace("_", " ").title()
# 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) storage_counts: OrderedDict[str, int] = OrderedDict((name, 0) for name in searched_backends)
normalized = _ensure_storage_columns(item_dict) for item in results or []:
# Add to table using normalized columns to avoid extra fields (e.g., Tags/Name) origin = getattr(item, 'origin', None)
table.add_result(normalized) 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) if storage_counts or query:
ctx.emit(normalized) 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)
# Set the result table in context for TUI/CLI display if summary_line:
ctx.set_last_result_table(table, results_list) table.title = summary_line
# Write results to worker stdout # Emit results and collect for workers table
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2)) if results:
else: for item in results:
log("No results found", file=sys.stderr) def _as_dict(obj: Any) -> Dict[str, Any]:
db.append_worker_stdout(worker_id, json.dumps([], indent=2)) if isinstance(obj, dict):
return dict(obj)
db.update_worker_status(worker_id, 'completed') if hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict")):
return 0 return obj.to_dict() # type: ignore[arg-type]
return {"title": str(obj)}
except Exception as exc:
log(f"Search failed: {exc}", file=sys.stderr) item_dict = _as_dict(item)
import traceback if store_filter:
traceback.print_exc(file=sys.stderr) origin_val = str(item_dict.get("origin") or item_dict.get("source") or "").lower()
if db: if store_filter != origin_val:
try: continue
db.update_worker_status(worker_id, 'error') normalized = _ensure_storage_columns(item_dict)
except Exception: # Add to table using normalized columns to avoid extra fields (e.g., Tags/Name)
pass table.add_result(normalized)
return 1
results_list.append(normalized)
finally: ctx.emit(normalized)
# Always close the database connection
if db: # Set the result table in context for TUI/CLI display
try: ctx.set_last_result_table(table, results_list)
db.close()
except Exception: # Write results to worker stdout
pass 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

View File

@@ -5,7 +5,7 @@ import platform
import socket import socket
import re import re
import subprocess import subprocess
from urllib.parse import urlparse from urllib.parse import urlparse, parse_qs
from pathlib import Path from pathlib import Path
from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args from cmdlets._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug from helper.logger import log, debug
@@ -87,6 +87,37 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
return None return None
def _normalize_playlist_target(text: Optional[str]) -> Optional[str]:
"""Normalize playlist entry targets for dedupe comparisons."""
if not text:
return None
real = _extract_target_from_memory_uri(text) or text
real = real.strip()
if not real:
return None
# If it's already a bare hydrus hash, use it directly
lower_real = real.lower()
if re.fullmatch(r"[0-9a-f]{64}", lower_real):
return lower_real
# If it's a hydrus file URL, normalize to the hash for dedupe
try:
parsed = urlparse(real)
if parsed.scheme in {"http", "https", "hydrus"}:
if parsed.path.endswith("/get_files/file"):
qs = parse_qs(parsed.query)
h = qs.get("hash", [None])[0]
if h and re.fullmatch(r"[0-9a-f]{64}", h.lower()):
return h.lower()
except Exception:
pass
# Normalize slashes for Windows paths and lowercase for comparison
real = real.replace('\\', '\\')
real = real.replace('\\', '\\')
return real.lower()
def _infer_store_from_playlist_item(item: Dict[str, Any]) -> str: def _infer_store_from_playlist_item(item: Dict[str, Any]) -> str:
"""Infer a friendly store label from an MPV playlist entry.""" """Infer a friendly store label from an MPV playlist entry."""
name = item.get("filename") if isinstance(item, dict) else None name = item.get("filename") if isinstance(item, dict) else None
@@ -97,6 +128,10 @@ def _infer_store_from_playlist_item(item: Dict[str, Any]) -> str:
if memory_target: if memory_target:
target = memory_target target = memory_target
# Hydrus hashes: bare 64-hex entries
if re.fullmatch(r"[0-9a-f]{64}", target.lower()):
return "hydrus"
lower = target.lower() lower = target.lower()
if lower.startswith("magnet:"): if lower.startswith("magnet:"):
return "magnet" return "magnet"
@@ -245,31 +280,36 @@ def _monitor_mpv_logs(duration: float = 3.0) -> None:
# Request log messages # Request log messages
client.send_command({"command": ["request_log_messages", "warn"]}) client.send_command({"command": ["request_log_messages", "warn"]})
# On Windows named pipes, avoid blocking the CLI; skip log read entirely
if client.is_windows:
client.disconnect()
return
import time import time
start_time = time.time() start_time = time.time()
# Unix sockets already have timeouts set; read until duration expires
while time.time() - start_time < duration: while time.time() - start_time < duration:
# We need to read raw lines from the socket try:
if client.is_windows: chunk = client.sock.recv(4096)
try: except socket.timeout:
line = client.sock.readline() continue
if line: except Exception:
try:
msg = json.loads(line)
if msg.get("event") == "log-message":
text = msg.get("text", "").strip()
prefix = msg.get("prefix", "")
level = msg.get("level", "")
if "ytdl" in prefix or level == "error":
debug(f"[MPV {prefix}] {text}", file=sys.stderr)
except json.JSONDecodeError:
pass
except Exception:
break
else:
# Unix socket handling (simplified)
break break
time.sleep(0.05) if not chunk:
break
for line in chunk.decode("utf-8", errors="ignore").splitlines():
try:
msg = json.loads(line)
if msg.get("event") == "log-message":
text = msg.get("text", "").strip()
prefix = msg.get("prefix", "")
level = msg.get("level", "")
if "ytdl" in prefix or level == "error":
debug(f"[MPV {prefix}] {text}", file=sys.stderr)
except json.JSONDecodeError:
continue
client.disconnect() client.disconnect()
except Exception: except Exception:
pass pass
@@ -294,6 +334,31 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
except Exception: except Exception:
hydrus_url = None hydrus_url = None
# Dedupe existing playlist before adding more (unless we're replacing it)
existing_targets: set[str] = set()
if not clear_first:
playlist = _get_playlist(silent=True) or []
dup_indexes: List[int] = []
for idx, pl_item in enumerate(playlist):
fname = pl_item.get("filename") if isinstance(pl_item, dict) else str(pl_item)
alt = pl_item.get("playlist-path") if isinstance(pl_item, dict) else None
norm = _normalize_playlist_target(fname) or _normalize_playlist_target(alt)
if not norm:
continue
if norm in existing_targets:
dup_indexes.append(idx)
else:
existing_targets.add(norm)
# Remove duplicates from playlist starting from the end to keep indices valid
for idx in reversed(dup_indexes):
try:
_send_ipc_command({"command": ["playlist-remove", idx], "request_id": 106}, silent=True)
except Exception:
pass
new_targets: set[str] = set()
for i, item in enumerate(items): for i, item in enumerate(items):
# Extract URL/Path # Extract URL/Path
target = None target = None
@@ -309,6 +374,16 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
target = item target = item
if target: if target:
# If we just have a hydrus hash, build a direct file URL for MPV
if re.fullmatch(r"[0-9a-f]{64}", str(target).strip().lower()) and hydrus_url:
target = f"{hydrus_url.rstrip('/')}/get_files/file?hash={str(target).strip()}"
norm_key = _normalize_playlist_target(target) or str(target).strip().lower()
if norm_key in existing_targets or norm_key in new_targets:
debug(f"Skipping duplicate playlist entry: {title or target}")
continue
new_targets.add(norm_key)
# Check if it's a yt-dlp supported URL # Check if it's a yt-dlp supported URL
is_ytdlp = False is_ytdlp = False
if target.startswith("http") and is_url_supported_by_ytdlp(target): if target.startswith("http") and is_url_supported_by_ytdlp(target):
@@ -699,7 +774,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Monitor logs briefly for errors (e.g. ytdl failures) # Monitor logs briefly for errors (e.g. ytdl failures)
_monitor_mpv_logs(3.0) _monitor_mpv_logs(3.0)
return 0
# Refresh playlist view so the user sees the new current item immediately
items = _get_playlist(silent=True) or items
list_mode = True
index_arg = None
else: else:
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}") debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
return 1 return 1

View File

@@ -1,10 +1,11 @@
"""Worker cmdlet: Display workers table in ResultTable format.""" """Worker cmdlet: Display workers table in ResultTable format."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Sequence, List
import json import json
import sys import sys
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Sequence, List
from cmdlets import register from cmdlets import register
from cmdlets._shared import Cmdlet, CmdletArg from cmdlets._shared import Cmdlet, CmdletArg
@@ -12,6 +13,9 @@ import pipeline as ctx
from helper.logger import log from helper.logger import log
from config import get_local_storage_path from config import get_local_storage_path
DEFAULT_LIMIT = 100
WORKER_STATUS_FILTERS = {"running", "completed", "error", "cancelled"}
HELP_FLAGS = {"-?", "/?", "--help", "-h", "help", "--cmdlet"}
CMDLET = Cmdlet( CMDLET = Cmdlet(
name=".worker", name=".worker",
@@ -21,6 +25,8 @@ CMDLET = Cmdlet(
CmdletArg("status", description="Filter by status: running, completed, error (default: all)"), CmdletArg("status", description="Filter by status: running, completed, error (default: all)"),
CmdletArg("limit", type="integer", description="Limit results (default: 100)"), CmdletArg("limit", type="integer", description="Limit results (default: 100)"),
CmdletArg("@N", description="Select worker by index (1-based) and display full logs"), CmdletArg("@N", description="Select worker by index (1-based) and display full logs"),
CmdletArg("-id", description="Show full logs for a specific worker"),
CmdletArg("-clear", type="flag", description="Remove completed workers from the database"),
], ],
details=[ details=[
"- Shows all background worker tasks and their output", "- Shows all background worker tasks and their output",
@@ -37,284 +43,285 @@ CMDLET = Cmdlet(
) )
def _has_help_flag(args_list: Sequence[str]) -> bool:
return any(str(arg).lower() in HELP_FLAGS for arg in args_list)
@dataclass
class WorkerCommandOptions:
status: str | None = None
limit: int = DEFAULT_LIMIT
worker_id: str | None = None
clear: bool = False
@register([".worker", "worker", "workers"]) @register([".worker", "worker", "workers"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Display workers table or show detailed logs for a specific worker.""" """Display workers table or show detailed logs for a specific worker."""
args_list = [str(arg) for arg in (args or [])] args_list = [str(arg) for arg in (args or [])]
selection_indices = ctx.get_last_selection() selection_indices = ctx.get_last_selection()
selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0 selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0
# Parse arguments for list view if _has_help_flag(args_list):
status_filter: str | None = None log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
limit = 100 return 0
clear_requested = False
worker_id_arg: str | None = None
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-limit", "--limit"} and i + 1 < len(args_list):
try:
limit = max(1, int(args_list[i + 1]))
except ValueError:
limit = 100
i += 2
elif low in {"-id", "--id"} and i + 1 < len(args_list):
worker_id_arg = args_list[i + 1]
i += 2
elif low in {"-clear", "--clear"}:
clear_requested = True
i += 1
elif low in {"running", "completed", "error", "cancelled"}:
status_filter = low
i += 1
elif not arg.startswith("-"):
status_filter = low
i += 1
else:
i += 1
try: options = _parse_worker_args(args_list)
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
log(json.dumps(CMDLET, ensure_ascii=False, indent=2))
return 0
except Exception:
pass
library_root = get_local_storage_path(config or {}) library_root = get_local_storage_path(config or {})
if not library_root: if not library_root:
log("No library root configured", file=sys.stderr) log("No library root configured", file=sys.stderr)
return 1 return 1
try: try:
from helper.local_library import LocalLibraryDB from helper.local_library import LocalLibraryDB
with LocalLibraryDB(library_root) as db:
if clear_requested:
count = db.clear_finished_workers()
log(f"Cleared {count} finished workers.")
return 0
if worker_id_arg:
worker = db.get_worker(worker_id_arg)
if worker:
events = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
else:
log(f"Worker not found: {worker_id_arg}", file=sys.stderr)
return 1
if selection_requested: with LocalLibraryDB(library_root) as db:
return _render_worker_selection(db, result) if options.clear:
return _render_worker_list(db, status_filter, limit) count = db.clear_finished_workers()
except Exception as exc: log(f"Cleared {count} finished workers.")
log(f"Workers query failed: {exc}", file=sys.stderr) return 0
import traceback
traceback.print_exc(file=sys.stderr) if options.worker_id:
return 1 worker = db.get_worker(options.worker_id)
if worker:
events: List[Dict[str, Any]] = []
try:
wid = worker.get("worker_id")
if wid and hasattr(db, "get_worker_events"):
events = db.get_worker_events(wid)
except Exception:
pass
_emit_worker_detail(worker, events)
return 0
log(f"Worker not found: {options.worker_id}", file=sys.stderr)
return 1
if selection_requested:
return _render_worker_selection(db, result)
return _render_worker_list(db, options.status, options.limit)
except Exception as exc:
log(f"Workers query failed: {exc}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return 1
def _parse_worker_args(args_list: Sequence[str]) -> WorkerCommandOptions:
options = WorkerCommandOptions()
i = 0
while i < len(args_list):
arg = args_list[i]
low = arg.lower()
if low in {"-limit", "--limit"} and i + 1 < len(args_list):
options.limit = _normalize_limit(args_list[i + 1])
i += 2
elif low in {"-id", "--id"} and i + 1 < len(args_list):
options.worker_id = args_list[i + 1]
i += 2
elif low in {"-clear", "--clear"}:
options.clear = True
i += 1
elif low in {"-status", "--status"} and i + 1 < len(args_list):
options.status = args_list[i + 1].lower()
i += 2
elif low in WORKER_STATUS_FILTERS:
options.status = low
i += 1
elif not arg.startswith("-"):
options.status = low
i += 1
else:
i += 1
return options
def _normalize_limit(value: Any) -> int:
try:
return max(1, int(value))
except (TypeError, ValueError):
return DEFAULT_LIMIT
def _render_worker_list(db, status_filter: str | None, limit: int) -> int: def _render_worker_list(db, status_filter: str | None, limit: int) -> int:
workers = db.get_all_workers(limit=limit) workers = db.get_all_workers(limit=limit)
if status_filter: if status_filter:
workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter] workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter]
if not workers: if not workers:
log("No workers found", file=sys.stderr) log("No workers found", file=sys.stderr)
return 0 return 0
for worker in workers: for worker in workers:
started = worker.get("started_at", "") started = worker.get("started_at", "")
ended = worker.get("completed_at", worker.get("last_updated", "")) ended = worker.get("completed_at", worker.get("last_updated", ""))
date_str = _extract_date(started) date_str = _extract_date(started)
start_time = _format_event_timestamp(started) start_time = _format_event_timestamp(started)
end_time = _format_event_timestamp(ended) end_time = _format_event_timestamp(ended)
item = { item = {
"columns": [ "columns": [
("Status", worker.get("status", "")), ("Status", worker.get("status", "")),
("Pipe", _summarize_pipe(worker.get("pipe"))), ("Pipe", _summarize_pipe(worker.get("pipe"))),
("Date", date_str), ("Date", date_str),
("Start Time", start_time), ("Start Time", start_time),
("End Time", end_time), ("End Time", end_time),
], ],
"__worker_metadata": worker, "__worker_metadata": worker,
"_selection_args": ["-id", worker.get("worker_id")] "_selection_args": ["-id", worker.get("worker_id")],
} }
ctx.emit(item) ctx.emit(item)
return 0 return 0
def _render_worker_selection(db, selected_items: Any) -> int: def _render_worker_selection(db, selected_items: Any) -> int:
if not isinstance(selected_items, list): if not isinstance(selected_items, list):
log("Selection payload missing", file=sys.stderr) log("Selection payload missing", file=sys.stderr)
return 1 return 1
emitted = False emitted = False
for item in selected_items: for item in selected_items:
worker = _resolve_worker_record(db, item) worker = _resolve_worker_record(db, item)
if not worker: if not worker:
continue continue
events = [] events: List[Dict[str, Any]] = []
try: try:
events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else [] events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else []
except Exception: except Exception:
events = [] events = []
_emit_worker_detail(worker, events) _emit_worker_detail(worker, events)
emitted = True emitted = True
if not emitted: if not emitted:
log("Selected rows no longer exist", file=sys.stderr) log("Selected rows no longer exist", file=sys.stderr)
return 1 return 1
return 0 return 0
def _resolve_worker_record(db, payload: Any) -> Dict[str, Any] | None: def _resolve_worker_record(db, payload: Any) -> Dict[str, Any] | None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
worker_data = payload.get("__worker_metadata") worker_data = payload.get("__worker_metadata")
worker_id = None worker_id = None
if isinstance(worker_data, dict): if isinstance(worker_data, dict):
worker_id = worker_data.get("worker_id") worker_id = worker_data.get("worker_id")
else: else:
worker_id = payload.get("worker_id") worker_id = payload.get("worker_id")
worker_data = None worker_data = None
if worker_id: if worker_id:
fresh = db.get_worker(worker_id) fresh = db.get_worker(worker_id)
if fresh: if fresh:
return fresh return fresh
return worker_data if isinstance(worker_data, dict) else None return worker_data if isinstance(worker_data, dict) else None
def _emit_worker_detail(worker: Dict[str, Any], events: List[Dict[str, Any]]) -> None: def _emit_worker_detail(worker: Dict[str, Any], events: List[Dict[str, Any]]) -> None:
# Parse stdout logs into rows stdout_content = worker.get("stdout", "") or ""
stdout_content = worker.get("stdout", "") or ""
lines = stdout_content.splitlines()
# Try to parse lines if they follow the standard log format
# Format: YYYY-MM-DD HH:MM:SS - name - level - message for line in lines:
lines = stdout_content.splitlines() line = line.strip()
if not line:
for line in lines: continue
line = line.strip()
if not line: timestamp = ""
continue level = "INFO"
message = line
# Default values
timestamp = "" try:
level = "INFO" parts = line.split(" - ", 3)
message = line if len(parts) >= 4:
ts_str, _, lvl, msg = parts
# Try to parse standard format timestamp = _format_event_timestamp(ts_str)
try: level = lvl
parts = line.split(" - ", 3) message = msg
if len(parts) >= 4: elif len(parts) == 3:
# Full format ts_str, lvl, msg = parts
ts_str, _, lvl, msg = parts timestamp = _format_event_timestamp(ts_str)
timestamp = _format_event_timestamp(ts_str) level = lvl
level = lvl message = msg
message = msg except Exception:
elif len(parts) == 3: pass
# Missing name or level
ts_str, lvl, msg = parts item = {
timestamp = _format_event_timestamp(ts_str) "columns": [
level = lvl ("Time", timestamp),
message = msg ("Level", level),
except Exception: ("Message", message),
pass ]
}
item = { ctx.emit(item)
"columns": [
("Time", timestamp), # Events are already always derived from stdout for now.
("Level", level),
("Message", message)
]
}
ctx.emit(item)
# Also emit events if available and not redundant
# (For now, just focusing on stdout logs as requested)
def _summarize_pipe(pipe_value: Any, limit: int = 60) -> str: def _summarize_pipe(pipe_value: Any, limit: int = 60) -> str:
text = str(pipe_value or "").strip() text = str(pipe_value or "").strip()
if not text: if not text:
return "(none)" return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..." return text if len(text) <= limit else text[: limit - 3] + "..."
def _format_event_timestamp(raw_timestamp: Any) -> str: def _format_event_timestamp(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp) dt = _parse_to_local(raw_timestamp)
if dt: if dt:
return dt.strftime("%H:%M:%S") return dt.strftime("%H:%M:%S")
if not raw_timestamp: if not raw_timestamp:
return "--:--:--" return "--:--:--"
text = str(raw_timestamp) text = str(raw_timestamp)
if "T" in text: if "T" in text:
time_part = text.split("T", 1)[1] time_part = text.split("T", 1)[1]
elif " " in text: elif " " in text:
time_part = text.split(" ", 1)[1] time_part = text.split(" ", 1)[1]
else: else:
time_part = text time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part return time_part[:8] if len(time_part) >= 8 else time_part
def _parse_to_local(timestamp_str: Any) -> datetime | None: def _parse_to_local(timestamp_str: Any) -> datetime | None:
if not timestamp_str: if not timestamp_str:
return None return None
text = str(timestamp_str).strip() text = str(timestamp_str).strip()
if not text: if not text:
return None return None
try: try:
# Check for T separator (Python isoformat - Local time) if "T" in text:
if 'T' in text: return datetime.fromisoformat(text)
return datetime.fromisoformat(text) if " " in text:
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
# Check for space separator (SQLite CURRENT_TIMESTAMP - UTC) dt = dt.replace(tzinfo=timezone.utc)
# Format: YYYY-MM-DD HH:MM:SS return dt.astimezone()
if ' ' in text: except Exception:
# Assume UTC pass
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S") return None
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone() # Convert to local
except Exception:
pass
return None
def _extract_date(raw_timestamp: Any) -> str: def _extract_date(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp) dt = _parse_to_local(raw_timestamp)
if dt: if dt:
return dt.strftime("%m-%d-%y") return dt.strftime("%m-%d-%y")
# Fallback if not raw_timestamp:
if not raw_timestamp: return ""
return "" text = str(raw_timestamp)
text = str(raw_timestamp) date_part = ""
# Extract YYYY-MM-DD part if "T" in text:
date_part = "" date_part = text.split("T", 1)[0]
if "T" in text: elif " " in text:
date_part = text.split("T", 1)[0] date_part = text.split(" ", 1)[0]
elif " " in text: else:
date_part = text.split(" ", 1)[0] date_part = text
else:
date_part = text try:
parts = date_part.split("-")
# Convert YYYY-MM-DD to MM-DD-YY if len(parts) == 3:
try: year, month, day = parts
parts = date_part.split("-") return f"{month}-{day}-{year[2:]}"
if len(parts) == 3: except Exception:
year, month, day = parts pass
return f"{month}-{day}-{year[2:]}" return date_part
except Exception:
pass
return date_part

View File

@@ -20,7 +20,7 @@ import time
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional from typing import Any, Dict, Iterator, List, Optional
from urllib.parse import urljoin from urllib.parse import urljoin, urlparse
import httpx import httpx
@@ -62,14 +62,11 @@ def _progress_callback(status: Dict[str, Any]) -> None:
percent = status.get("_percent_str", "?") percent = status.get("_percent_str", "?")
speed = status.get("_speed_str", "?") speed = status.get("_speed_str", "?")
eta = status.get("_eta_str", "?") eta = status.get("_eta_str", "?")
# Print progress to stdout with carriage return to update in place
sys.stdout.write(f"\r[download] {percent} at {speed} ETA {eta} ") sys.stdout.write(f"\r[download] {percent} at {speed} ETA {eta} ")
sys.stdout.flush() sys.stdout.flush()
elif event == "finished": elif event == "finished":
# Clear the progress line
sys.stdout.write("\r" + " " * 70 + "\r") sys.stdout.write("\r" + " " * 70 + "\r")
sys.stdout.flush() sys.stdout.flush()
# Log finished message (visible)
debug(f"✓ Download finished: {status.get('filename')}") debug(f"✓ Download finished: {status.get('filename')}")
elif event in ("postprocessing", "processing"): elif event in ("postprocessing", "processing"):
debug(f"Post-processing: {status.get('postprocessor')}") debug(f"Post-processing: {status.get('postprocessor')}")
@@ -99,17 +96,7 @@ def is_url_supported_by_ytdlp(url: str) -> bool:
def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[str] = None) -> Optional[List[Dict[str, Any]]]: def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
"""Get list of available formats for a URL using yt-dlp. """Get list of available formats for a URL using yt-dlp."""
Args:
url: URL to get formats for
no_playlist: If True, ignore playlists and list formats for single video
playlist_items: If specified, only list formats for these playlist items (e.g., "1,3,5-8")
Returns:
List of format dictionaries with keys: format_id, format, resolution, fps, vcodec, acodec, filesize, etc.
Returns None if yt-dlp is not available or format listing fails.
"""
_ensure_yt_dlp_ready() _ensure_yt_dlp_ready()
try: try:
@@ -118,28 +105,25 @@ def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[s
"no_warnings": True, "no_warnings": True,
"socket_timeout": 30, "socket_timeout": 30,
} }
# Add no_playlist option if specified
if no_playlist: if no_playlist:
ydl_opts["noplaylist"] = True ydl_opts["noplaylist"] = True
# Add playlist_items filter if specified
if playlist_items: if playlist_items:
ydl_opts["playlist_items"] = playlist_items ydl_opts["playlist_items"] = playlist_items
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
debug(f"Fetching format list for: {url}") debug(f"Fetching format list for: {url}")
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
formats = info.get("formats", []) formats = info.get("formats", [])
if not formats: if not formats:
log("No formats available", file=sys.stderr) log("No formats available", file=sys.stderr)
return None return None
# Parse and extract relevant format info
result_formats = [] result_formats = []
for fmt in formats: for fmt in formats:
format_info = { result_formats.append({
"format_id": fmt.get("format_id", ""), "format_id": fmt.get("format_id", ""),
"format": fmt.get("format", ""), "format": fmt.get("format", ""),
"ext": fmt.get("ext", ""), "ext": fmt.get("ext", ""),
@@ -150,13 +134,12 @@ def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[s
"vcodec": fmt.get("vcodec", "none"), "vcodec": fmt.get("vcodec", "none"),
"acodec": fmt.get("acodec", "none"), "acodec": fmt.get("acodec", "none"),
"filesize": fmt.get("filesize"), "filesize": fmt.get("filesize"),
"tbr": fmt.get("tbr"), # Total bitrate "tbr": fmt.get("tbr"),
} })
result_formats.append(format_info)
debug(f"Found {len(result_formats)} available formats") debug(f"Found {len(result_formats)} available formats")
return result_formats return result_formats
except Exception as e: except Exception as e:
log(f"✗ Error fetching formats: {e}", file=sys.stderr) log(f"✗ Error fetching formats: {e}", file=sys.stderr)
return None return None
@@ -779,8 +762,28 @@ def download_media(
debug_logger.write_record("libgen-resolve-failed", {"url": opts.url}) debug_logger.write_record("libgen-resolve-failed", {"url": opts.url})
return _download_direct_file(opts.url, opts.output_dir, debug_logger) return _download_direct_file(opts.url, opts.output_dir, debug_logger)
# Try yt-dlp first if URL is supported # Handle GoFile shares with a dedicated resolver before yt-dlp/direct fallbacks
if not is_url_supported_by_ytdlp(opts.url): try:
netloc = urlparse(opts.url).netloc.lower()
except Exception:
netloc = ""
if "gofile.io" in netloc:
msg = "GoFile links are currently unsupported"
debug(msg)
if debug_logger is not None:
debug_logger.write_record("gofile-unsupported", {"url": opts.url})
raise DownloadError(msg)
# Determine if yt-dlp should be used
ytdlp_supported = is_url_supported_by_ytdlp(opts.url)
if ytdlp_supported:
probe_result = probe_url(opts.url, no_playlist=opts.no_playlist)
if probe_result is None:
log(f"URL supported by yt-dlp but no media detected, falling back to direct download: {opts.url}")
if debug_logger is not None:
debug_logger.write_record("ytdlp-skip-no-media", {"url": opts.url})
return _download_direct_file(opts.url, opts.output_dir, debug_logger)
else:
log(f"URL not supported by yt-dlp, trying direct download: {opts.url}") log(f"URL not supported by yt-dlp, trying direct download: {opts.url}")
if debug_logger is not None: if debug_logger is not None:
debug_logger.write_record("direct-file-attempt", {"url": opts.url}) debug_logger.write_record("direct-file-attempt", {"url": opts.url})

View File

@@ -28,6 +28,41 @@ import re
from helper.logger import log, debug from helper.logger import log, debug
from helper.utils_constant import mime_maps from helper.utils_constant import mime_maps
from helper.utils import sha256_file
HEX_DIGITS = set("0123456789abcdef")
def _normalize_hex_hash(value: Optional[str]) -> Optional[str]:
"""Return a normalized 64-character lowercase hash or None."""
if value is None:
return None
try:
cleaned = ''.join(ch for ch in str(value).strip().lower() if ch in HEX_DIGITS)
except Exception:
return None
if len(cleaned) == 64:
return cleaned
return None
def _resolve_file_hash(candidate: Optional[str], path: Path) -> Optional[str]:
"""Return the given hash if valid, otherwise compute sha256 from disk."""
normalized = _normalize_hex_hash(candidate)
if normalized is not None:
return normalized
if not path.exists():
return None
try:
return sha256_file(path)
except Exception as exc:
debug(f"Failed to compute hash for {path}: {exc}")
return None
class StorageBackend(ABC): class StorageBackend(ABC):
@@ -198,6 +233,39 @@ class LocalStorageBackend(StorageBackend):
search_dir = Path(location).expanduser() search_dir = Path(location).expanduser()
debug(f"Searching local storage at: {search_dir}") debug(f"Searching local storage at: {search_dir}")
# Support comma-separated AND queries (token1,token2,...). Each token must match.
tokens = [t.strip() for t in query.split(',') if t.strip()]
# Require explicit namespace for hash lookups to avoid accidental filename matches
if not match_all and len(tokens) == 1 and _normalize_hex_hash(query_lower):
debug("Hash queries require 'hash:' prefix for local search")
return results
# Require explicit namespace for hash lookups to avoid accidental filename matches
if not match_all and _normalize_hex_hash(query_lower):
debug("Hash queries require 'hash:' prefix for local search")
return results
def _create_entry(file_path: Path, tags: list[str], size_bytes: int | None, db_hash: Optional[str]) -> dict[str, Any]:
path_str = str(file_path)
entry = {
"name": file_path.stem,
"title": next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), file_path.stem),
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": tags,
}
hash_value = _resolve_file_hash(db_hash, file_path)
if hash_value:
entry["hash"] = hash_value
entry["hash_hex"] = hash_value
entry["file_hash"] = hash_value
return entry
try: try:
if not search_dir.exists(): if not search_dir.exists():
debug(f"Search directory does not exist: {search_dir}") debug(f"Search directory does not exist: {search_dir}")
@@ -209,17 +277,196 @@ class LocalStorageBackend(StorageBackend):
cursor = db.connection.cursor() cursor = db.connection.cursor()
# Check if query is a tag namespace search (format: "namespace:pattern") # Check if query is a tag namespace search (format: "namespace:pattern")
if tokens and len(tokens) > 1:
# AND mode across comma-separated tokens
def _like_pattern(term: str) -> str:
return term.replace('*', '%').replace('?', '_')
def _ids_for_token(token: str, cursor) -> set[int]:
token = token.strip()
if not token:
return set()
# Namespaced token
if ':' in token and not token.startswith(':'):
namespace, pattern = token.split(':', 1)
namespace = namespace.strip().lower()
pattern = pattern.strip().lower()
if namespace == 'hash':
normalized_hash = _normalize_hex_hash(pattern)
if not normalized_hash:
return set()
cursor.execute(
"""
SELECT id FROM files
WHERE LOWER(file_hash) = ?
""",
(normalized_hash,)
)
return {row[0] for row in cursor.fetchall()}
if namespace == 'store':
# Local backend only serves local store
if pattern not in {'local', 'file', 'filesystem'}:
return set()
cursor.execute("SELECT id FROM files")
return {row[0] for row in cursor.fetchall()}
# Generic namespace match on tags
query_pattern = f"{namespace}:%"
cursor.execute(
"""
SELECT DISTINCT f.id, t.tag
FROM files f
JOIN tags t ON f.id = t.file_id
WHERE LOWER(t.tag) LIKE ?
""",
(query_pattern,)
)
matched: set[int] = set()
for file_id, tag_val in cursor.fetchall():
if not tag_val:
continue
tag_lower = str(tag_val).lower()
if not tag_lower.startswith(f"{namespace}:"):
continue
value = tag_lower[len(namespace)+1:]
if fnmatch(value, pattern):
matched.add(int(file_id))
return matched
# Bare token: match filename OR any tag (including title)
term = token.lower()
like_pattern = f"%{_like_pattern(term)}%"
ids: set[int] = set()
# Filename match
cursor.execute(
"""
SELECT DISTINCT id FROM files
WHERE LOWER(file_path) LIKE ?
""",
(like_pattern,)
)
ids.update(int(row[0]) for row in cursor.fetchall())
# Tag match (any namespace, including title)
cursor.execute(
"""
SELECT DISTINCT f.id
FROM files f
JOIN tags t ON f.id = t.file_id
WHERE LOWER(t.tag) LIKE ?
""",
(like_pattern,)
)
ids.update(int(row[0]) for row in cursor.fetchall())
return ids
try:
with LocalLibraryDB(search_dir) as db:
cursor = db.connection.cursor()
matching_ids: set[int] | None = None
for token in tokens:
ids = _ids_for_token(token, cursor)
matching_ids = ids if matching_ids is None else matching_ids & ids
if not matching_ids:
return results
if not matching_ids:
return results
# Fetch rows for matching IDs
placeholders = ",".join(["?"] * len(matching_ids))
fetch_sql = f"""
SELECT id, file_path, file_size, file_hash
FROM files
WHERE id IN ({placeholders})
ORDER BY file_path
LIMIT ?
"""
cursor.execute(fetch_sql, (*matching_ids, limit or len(matching_ids)))
rows = cursor.fetchall()
for file_id, file_path_str, size_bytes, file_hash in rows:
if not file_path_str:
continue
file_path = Path(file_path_str)
if not file_path.exists():
continue
if size_bytes is None:
try:
size_bytes = file_path.stat().st_size
except OSError:
size_bytes = None
cursor.execute(
"""
SELECT tag FROM tags WHERE file_id = ?
""",
(file_id,),
)
tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
return results
except Exception as exc:
log(f"⚠️ AND search failed: {exc}", file=sys.stderr)
debug(f"AND search exception details: {exc}")
return []
if ":" in query and not query.startswith(":"): if ":" in query and not query.startswith(":"):
namespace, pattern = query.split(":", 1) namespace, pattern = query.split(":", 1)
namespace = namespace.strip().lower() namespace = namespace.strip().lower()
pattern = pattern.strip().lower() pattern = pattern.strip().lower()
debug(f"Performing namespace search: {namespace}:{pattern}") debug(f"Performing namespace search: {namespace}:{pattern}")
# Special-case hash: lookups against file_hash column
if namespace == "hash":
normalized_hash = _normalize_hex_hash(pattern)
if not normalized_hash:
return results
cursor.execute(
"""
SELECT id, file_path, file_size, file_hash
FROM files
WHERE LOWER(file_hash) = ?
ORDER BY file_path
LIMIT ?
""",
(normalized_hash, limit or 1000),
)
for file_id, file_path_str, size_bytes, file_hash in cursor.fetchall():
if not file_path_str:
continue
file_path = Path(file_path_str)
if not file_path.exists():
continue
if size_bytes is None:
try:
size_bytes = file_path.stat().st_size
except OSError:
size_bytes = None
cursor.execute(
"""
SELECT tag FROM tags WHERE file_id = ?
""",
(file_id,),
)
all_tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, all_tags, size_bytes, file_hash)
results.append(entry)
if limit is not None and len(results) >= limit:
return results
return results
# Search for tags matching the namespace and pattern # Search for tags matching the namespace and pattern
query_pattern = f"{namespace}:%" query_pattern = f"{namespace}:%"
cursor.execute(""" cursor.execute("""
SELECT DISTINCT f.id, f.file_path, f.file_size SELECT DISTINCT f.id, f.file_path, f.file_size, f.file_hash
FROM files f FROM files f
JOIN tags t ON f.id = t.file_id JOIN tags t ON f.id = t.file_id
WHERE LOWER(t.tag) LIKE ? WHERE LOWER(t.tag) LIKE ?
@@ -231,7 +478,7 @@ class LocalStorageBackend(StorageBackend):
debug(f"Found {len(rows)} potential matches in DB") debug(f"Found {len(rows)} potential matches in DB")
# Filter results by pattern match # Filter results by pattern match
for file_id, file_path_str, size_bytes in rows: for file_id, file_path_str, size_bytes, file_hash in rows:
if not file_path_str: if not file_path_str:
continue continue
@@ -254,30 +501,14 @@ class LocalStorageBackend(StorageBackend):
if fnmatch(value, pattern): if fnmatch(value, pattern):
file_path = Path(file_path_str) file_path = Path(file_path_str)
if file_path.exists(): if file_path.exists():
path_str = str(file_path)
if size_bytes is None: if size_bytes is None:
size_bytes = file_path.stat().st_size size_bytes = file_path.stat().st_size
# Fetch all tags for this file
cursor.execute(""" cursor.execute("""
SELECT tag FROM tags WHERE file_id = ? SELECT tag FROM tags WHERE file_id = ?
""", (file_id,)) """, (file_id,))
all_tags = [row[0] for row in cursor.fetchall()] all_tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, all_tags, size_bytes, file_hash)
# Use title tag if present results.append(entry)
title_tag = next((t.split(':', 1)[1] for t in all_tags if t.lower().startswith('title:')), None)
results.append({
"name": file_path.stem,
"title": title_tag or file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": all_tags,
})
else: else:
debug(f"File missing on disk: {file_path}") debug(f"File missing on disk: {file_path}")
break # Don't add same file multiple times break # Don't add same file multiple times
@@ -309,7 +540,7 @@ class LocalStorageBackend(StorageBackend):
where_clause = " AND ".join(conditions) where_clause = " AND ".join(conditions)
cursor.execute(f""" cursor.execute(f"""
SELECT DISTINCT f.id, f.file_path, f.file_size SELECT DISTINCT f.id, f.file_path, f.file_size, f.file_hash
FROM files f FROM files f
WHERE {where_clause} WHERE {where_clause}
ORDER BY f.file_path ORDER BY f.file_path
@@ -344,7 +575,7 @@ class LocalStorageBackend(StorageBackend):
word_regex = None word_regex = None
seen_files = set() seen_files = set()
for file_id, file_path_str, size_bytes in rows: for file_id, file_path_str, size_bytes, file_hash in rows:
if not file_path_str or file_path_str in seen_files: if not file_path_str or file_path_str in seen_files:
continue continue
@@ -361,26 +592,12 @@ class LocalStorageBackend(StorageBackend):
if size_bytes is None: if size_bytes is None:
size_bytes = file_path.stat().st_size size_bytes = file_path.stat().st_size
# Fetch tags for this file
cursor.execute(""" cursor.execute("""
SELECT tag FROM tags WHERE file_id = ? SELECT tag FROM tags WHERE file_id = ?
""", (file_id,)) """, (file_id,))
tags = [row[0] for row in cursor.fetchall()] tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, tags, size_bytes, file_hash)
# Use title tag if present results.append(entry)
title_tag = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None)
results.append({
"name": file_path.stem,
"title": title_tag or file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": tags,
})
if limit is not None and len(results) >= limit: if limit is not None and len(results) >= limit:
return results return results
@@ -390,7 +607,7 @@ class LocalStorageBackend(StorageBackend):
for term in terms: for term in terms:
cursor.execute( cursor.execute(
""" """
SELECT DISTINCT f.id, f.file_path, f.file_size SELECT DISTINCT f.id, f.file_path, f.file_size, f.file_hash
FROM files f FROM files f
JOIN tags t ON f.id = t.file_id JOIN tags t ON f.id = t.file_id
WHERE LOWER(t.tag) LIKE ? WHERE LOWER(t.tag) LIKE ?
@@ -399,7 +616,7 @@ class LocalStorageBackend(StorageBackend):
""", """,
(f"title:%{term}%", fetch_limit), (f"title:%{term}%", fetch_limit),
) )
for file_id, file_path_str, size_bytes in cursor.fetchall(): for file_id, file_path_str, size_bytes, file_hash in cursor.fetchall():
if not file_path_str: if not file_path_str:
continue continue
entry = title_hits.get(file_id) entry = title_hits.get(file_id)
@@ -411,6 +628,7 @@ class LocalStorageBackend(StorageBackend):
title_hits[file_id] = { title_hits[file_id] = {
"path": file_path_str, "path": file_path_str,
"size": size_bytes, "size": size_bytes,
"hash": file_hash,
"count": 1, "count": 1,
} }
@@ -441,19 +659,8 @@ class LocalStorageBackend(StorageBackend):
(file_id,), (file_id,),
) )
tags = [row[0] for row in cursor.fetchall()] tags = [row[0] for row in cursor.fetchall()]
title_tag = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None) entry = _create_entry(file_path, tags, size_bytes, info.get("hash"))
results.append(entry)
results.append({
"name": file_path.stem,
"title": title_tag or file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": str(file_path),
"target": str(file_path),
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": tags,
})
if limit is not None and len(results) >= limit: if limit is not None and len(results) >= limit:
return results return results
@@ -465,7 +672,7 @@ class LocalStorageBackend(StorageBackend):
query_pattern = f"%{query_lower}%" query_pattern = f"%{query_lower}%"
cursor.execute(""" cursor.execute("""
SELECT DISTINCT f.id, f.file_path, f.file_size SELECT DISTINCT f.id, f.file_path, f.file_size, f.file_hash
FROM files f FROM files f
JOIN tags t ON f.id = t.file_id JOIN tags t ON f.id = t.file_id
WHERE LOWER(t.tag) LIKE ? AND LOWER(t.tag) NOT LIKE '%:%' WHERE LOWER(t.tag) LIKE ? AND LOWER(t.tag) NOT LIKE '%:%'
@@ -474,7 +681,7 @@ class LocalStorageBackend(StorageBackend):
""", (query_pattern, limit or 1000)) """, (query_pattern, limit or 1000))
tag_rows = cursor.fetchall() tag_rows = cursor.fetchall()
for file_id, file_path_str, size_bytes in tag_rows: for file_id, file_path_str, size_bytes, file_hash in tag_rows:
if not file_path_str or file_path_str in seen_files: if not file_path_str or file_path_str in seen_files:
continue continue
seen_files.add(file_path_str) seen_files.add(file_path_str)
@@ -490,21 +697,8 @@ class LocalStorageBackend(StorageBackend):
SELECT tag FROM tags WHERE file_id = ? SELECT tag FROM tags WHERE file_id = ?
""", (file_id,)) """, (file_id,))
tags = [row[0] for row in cursor.fetchall()] tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, tags, size_bytes, file_hash)
# Use title tag if present results.append(entry)
title_tag = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None)
results.append({
"name": file_path.stem,
"title": title_tag or file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": tags,
})
if limit is not None and len(results) >= limit: if limit is not None and len(results) >= limit:
return results return results
@@ -512,14 +706,14 @@ class LocalStorageBackend(StorageBackend):
else: else:
# Match all - get all files from database # Match all - get all files from database
cursor.execute(""" cursor.execute("""
SELECT id, file_path, file_size SELECT id, file_path, file_size, file_hash
FROM files FROM files
ORDER BY file_path ORDER BY file_path
LIMIT ? LIMIT ?
""", (limit or 1000,)) """, (limit or 1000,))
rows = cursor.fetchall() rows = cursor.fetchall()
for file_id, file_path_str, size_bytes in rows: for file_id, file_path_str, size_bytes, file_hash in rows:
if file_path_str: if file_path_str:
file_path = Path(file_path_str) file_path = Path(file_path_str)
if file_path.exists(): if file_path.exists():
@@ -532,21 +726,8 @@ class LocalStorageBackend(StorageBackend):
SELECT tag FROM tags WHERE file_id = ? SELECT tag FROM tags WHERE file_id = ?
""", (file_id,)) """, (file_id,))
tags = [row[0] for row in cursor.fetchall()] tags = [row[0] for row in cursor.fetchall()]
entry = _create_entry(file_path, tags, size_bytes, file_hash)
# Use title tag if present results.append(entry)
title_tag = next((t.split(':', 1)[1] for t in tags if t.lower().startswith('title:')), None)
results.append({
"name": file_path.stem,
"title": title_tag or file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
"size": size_bytes,
"size_bytes": size_bytes,
"tags": tags,
})
if results: if results:
debug(f"Returning {len(results)} results from DB") debug(f"Returning {len(results)} results from DB")

View File

@@ -22,6 +22,7 @@ from typing import Optional, Dict, Any, List, Tuple, Set
from .utils import sha256_file from .utils import sha256_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WORKER_LOG_MAX_ENTRIES = 99
# Try to import optional dependencies # Try to import optional dependencies
try: try:
@@ -352,6 +353,29 @@ class LocalLibraryDB:
INSERT INTO worker_log (worker_id, event_type, step, channel, message) INSERT INTO worker_log (worker_id, event_type, step, channel, message)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
""", (worker_id, event_type, step, channel, message)) """, (worker_id, event_type, step, channel, message))
self._prune_worker_log_entries(cursor, worker_id)
def _prune_worker_log_entries(self, cursor, worker_id: str) -> None:
"""Keep at most WORKER_LOG_MAX_ENTRIES rows per worker by trimming oldest ones."""
if WORKER_LOG_MAX_ENTRIES <= 0:
return
cursor.execute(
"""
SELECT id FROM worker_log
WHERE worker_id = ?
ORDER BY id DESC
LIMIT 1 OFFSET ?
""",
(worker_id, WORKER_LOG_MAX_ENTRIES - 1),
)
row = cursor.fetchone()
if not row:
return
cutoff_id = row[0]
cursor.execute(
"DELETE FROM worker_log WHERE worker_id = ? AND id < ?",
(worker_id, cutoff_id),
)
def get_worker_events(self, worker_id: str, limit: int = 500) -> List[Dict[str, Any]]: def get_worker_events(self, worker_id: str, limit: int = 500) -> List[Dict[str, Any]]:
"""Return chronological worker log events for timelines.""" """Return chronological worker log events for timelines."""

View File

@@ -7,6 +7,11 @@ import sys
from helper.logger import log, debug from helper.logger import log, debug
try: # Optional dependency
import musicbrainzngs # type: ignore
except ImportError: # pragma: no cover - optional
musicbrainzngs = None
class MetadataProvider(ABC): class MetadataProvider(ABC):
"""Base class for metadata providers (music, movies, books, etc.).""" """Base class for metadata providers (music, movies, books, etc.)."""
@@ -266,6 +271,86 @@ class GoogleBooksMetadataProvider(MetadataProvider):
return tags return tags
class MusicBrainzMetadataProvider(MetadataProvider):
"""Metadata provider for MusicBrainz recordings."""
@property
def name(self) -> str: # type: ignore[override]
return "musicbrainz"
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
if not musicbrainzngs:
log("musicbrainzngs is not installed; skipping MusicBrainz scrape", file=sys.stderr)
return []
q = (query or "").strip()
if not q:
return []
try:
# Ensure user agent is set (required by MusicBrainz)
musicbrainzngs.set_useragent("Medeia-Macina", "0.1")
except Exception:
pass
try:
resp = musicbrainzngs.search_recordings(query=q, limit=limit)
recordings = resp.get("recording-list") or resp.get("recordings") or []
except Exception as exc:
log(f"MusicBrainz search failed: {exc}", file=sys.stderr)
return []
items: List[Dict[str, Any]] = []
for rec in recordings[:limit]:
if not isinstance(rec, dict):
continue
title = rec.get("title") or ""
artist = ""
artist_credit = rec.get("artist-credit") or rec.get("artist_credit")
if isinstance(artist_credit, list) and artist_credit:
first = artist_credit[0]
if isinstance(first, dict):
artist = first.get("name") or first.get("artist", {}).get("name", "")
elif isinstance(first, str):
artist = first
album = ""
release_list = rec.get("release-list") or rec.get("releases") or rec.get("release")
if isinstance(release_list, list) and release_list:
first_rel = release_list[0]
if isinstance(first_rel, dict):
album = first_rel.get("title", "") or ""
release_date = first_rel.get("date") or ""
else:
album = str(first_rel)
release_date = ""
else:
release_date = rec.get("first-release-date") or ""
year = str(release_date)[:4] if release_date else ""
mbid = rec.get("id") or ""
items.append({
"title": title,
"artist": artist,
"album": album,
"year": year,
"provider": self.name,
"mbid": mbid,
"raw": rec,
})
return items
def to_tags(self, item: Dict[str, Any]) -> List[str]:
tags = super().to_tags(item)
mbid = item.get("mbid")
if mbid:
tags.append(f"musicbrainz:{mbid}")
return tags
# Registry --------------------------------------------------------------- # Registry ---------------------------------------------------------------
_METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = { _METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = {
@@ -273,6 +358,7 @@ _METADATA_PROVIDERS: Dict[str, Type[MetadataProvider]] = {
"openlibrary": OpenLibraryMetadataProvider, "openlibrary": OpenLibraryMetadataProvider,
"googlebooks": GoogleBooksMetadataProvider, "googlebooks": GoogleBooksMetadataProvider,
"google": GoogleBooksMetadataProvider, "google": GoogleBooksMetadataProvider,
"musicbrainz": MusicBrainzMetadataProvider,
} }

View File

@@ -12,6 +12,7 @@ import os
import platform import platform
import socket import socket
import time as _time import time as _time
from pathlib import Path
from typing import Any, Dict, Optional, List from typing import Any, Dict, Optional, List
from helper.logger import debug from helper.logger import debug
@@ -19,6 +20,7 @@ from helper.logger import debug
# Fixed pipe name for persistent MPV connection across all Python sessions # Fixed pipe name for persistent MPV connection across all Python sessions
FIXED_IPC_PIPE_NAME = "mpv-medeia-macina" FIXED_IPC_PIPE_NAME = "mpv-medeia-macina"
MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent.parent / "LUA" / "main.lua")
class MPVIPCError(Exception): class MPVIPCError(Exception):
@@ -45,6 +47,48 @@ def get_ipc_pipe_path() -> str:
return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock"
def _unwrap_memory_target(text: Optional[str]) -> Optional[str]:
"""Return the real target from a memory:// M3U payload if present."""
if not isinstance(text, str) or not text.startswith("memory://"):
return text
for line in text.splitlines():
line = line.strip()
if not line or line.startswith('#') or line.startswith('memory://'):
continue
return line
return text
def _normalize_target(text: Optional[str]) -> Optional[str]:
"""Normalize playlist targets for deduping across raw/memory:// wrappers."""
if not text:
return None
real = _unwrap_memory_target(text)
if not real:
return None
real = real.strip()
if not real:
return None
lower = real.lower()
# Hydrus bare hash
if len(lower) == 64 and all(ch in "0123456789abcdef" for ch in lower):
return lower
# Hydrus file URL with hash query
try:
parsed = __import__("urllib.parse").parse.urlparse(real)
qs = __import__("urllib.parse").parse.parse_qs(parsed.query)
hash_qs = qs.get("hash", [None])[0]
if hash_qs and len(hash_qs) == 64 and all(ch in "0123456789abcdef" for ch in hash_qs.lower()):
return hash_qs.lower()
except Exception:
pass
# Normalize paths/urls for comparison
return lower.replace('\\', '\\')
class MPVIPCClient: class MPVIPCClient:
"""Client for communicating with mpv via IPC socket/pipe. """Client for communicating with mpv via IPC socket/pipe.
@@ -171,11 +215,18 @@ class MPVIPCClient:
# Check if this is the response to our request # Check if this is the response to our request
if resp.get("request_id") == request.get("request_id"): if resp.get("request_id") == request.get("request_id"):
return resp return resp
# If it's an error without request_id (shouldn't happen for commands) # Handle async log messages/events for visibility
if "error" in resp and "request_id" not in resp: event_type = resp.get("event")
# Might be an event or async error if event_type == "log-message":
pass level = resp.get("level", "info")
prefix = resp.get("prefix", "")
text = resp.get("text", "").strip()
debug(f"[MPV {level}] {prefix} {text}".strip())
elif event_type:
debug(f"[MPV event] {event_type}: {resp}")
elif "error" in resp and "request_id" not in resp:
debug(f"[MPV error] {resp}")
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
@@ -230,7 +281,13 @@ def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = N
return False return False
try: try:
# Command 1: Set headers if provided # Command 0: Subscribe to log messages so MPV console errors surface in REPL
_subscribe_log_messages(client)
# Command 1: Ensure our Lua helper is loaded for in-window controls
_ensure_lua_script_loaded(client)
# Command 2: Set headers if provided
if headers: if headers:
header_str = ",".join([f"{k}: {v}" for k, v in headers.items()]) header_str = ",".join([f"{k}: {v}" for k, v in headers.items()])
cmd_headers = { cmd_headers = {
@@ -238,22 +295,46 @@ def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = N
"request_id": 0 "request_id": 0
} }
client.send_command(cmd_headers) client.send_command(cmd_headers)
# Deduplicate: if target already exists in playlist, just play it
normalized_new = _normalize_target(file_url)
existing_index = None
existing_title = None
if normalized_new:
playlist_resp = client.send_command({"command": ["get_property", "playlist"], "request_id": 98})
if playlist_resp and playlist_resp.get("error") == "success":
for idx, item in enumerate(playlist_resp.get("data", []) or []):
for key in ("playlist-path", "filename"):
norm_existing = _normalize_target(item.get(key)) if isinstance(item, dict) else None
if norm_existing and norm_existing == normalized_new:
existing_index = idx
existing_title = item.get("title") if isinstance(item, dict) else None
break
if existing_index is not None:
break
if existing_index is not None and append:
play_cmd = {"command": ["playlist-play-index", existing_index], "request_id": 99}
play_resp = client.send_command(play_cmd)
if play_resp and play_resp.get("error") == "success":
client.send_command({"command": ["set_property", "pause", False], "request_id": 100})
safe_title = (title or existing_title or "").replace("\n", " ").replace("\r", " ").strip()
if safe_title:
client.send_command({"command": ["set_property", "force-media-title", safe_title], "request_id": 101})
debug(f"Already in playlist, playing existing entry: {safe_title or file_url}")
return True
# Command 2: Load file # Command 2: Load file and inject title via memory:// wrapper so playlist shows friendly names immediately
# Use memory:// M3U to preserve title in playlist if provided target = file_url
# This is required for YouTube URLs and proper playlist display
if title:
# Sanitize title for M3U (remove newlines)
safe_title = title.replace("\n", " ").replace("\r", "")
# M3U format: #EXTM3U\n#EXTINF:-1,Title\nURL
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{file_url}"
target = f"memory://{m3u_content}"
else:
target = file_url
load_mode = "append-play" if append else "replace" load_mode = "append-play" if append else "replace"
safe_title = (title or "").replace("\n", " ").replace("\r", " ").strip()
target_to_send = target
if safe_title and not str(target).startswith("memory://"):
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
target_to_send = f"memory://{m3u_content}"
cmd_load = { cmd_load = {
"command": ["loadfile", target, load_mode], "command": ["loadfile", target_to_send, load_mode],
"request_id": 1 "request_id": 1
} }
@@ -263,14 +344,14 @@ def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = N
return False return False
# Command 3: Set title (metadata for display) - still useful for window title # Command 3: Set title (metadata for display) - still useful for window title
if title: if safe_title:
cmd_title = { cmd_title = {
"command": ["set_property", "force-media-title", title], "command": ["set_property", "force-media-title", safe_title],
"request_id": 2 "request_id": 2
} }
client.send_command(cmd_title) client.send_command(cmd_title)
debug(f"Sent to existing MPV: {title}") debug(f"Sent to existing MPV: {safe_title or title}")
return True return True
except Exception as e: except Exception as e:
@@ -295,3 +376,29 @@ def get_mpv_client(socket_path: Optional[str] = None) -> Optional[MPVIPCClient]:
return client return client
return None return None
def _subscribe_log_messages(client: MPVIPCClient) -> None:
"""Ask MPV to emit log messages over IPC so we can surface console errors."""
try:
client.send_command({"command": ["request_log_messages", "warn"], "request_id": 11})
except Exception as exc:
debug(f"Failed to subscribe to MPV logs: {exc}")
def _ensure_lua_script_loaded(client: MPVIPCClient) -> None:
"""Load the bundled MPV Lua script to enable in-window controls.
Safe to call repeatedly; mpv will simply reload the script if already present.
"""
try:
script_path = MPV_LUA_SCRIPT_PATH
if not script_path or not os.path.exists(script_path):
return
resp = client.send_command({"command": ["load-script", script_path], "request_id": 12})
if resp and resp.get("error") == "success":
debug(f"Loaded MPV Lua script: {script_path}")
else:
debug(f"MPV Lua load response: {resp}")
except Exception as exc:
debug(f"Failed to load MPV Lua script: {exc}")

View File

@@ -55,16 +55,16 @@ def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[st
is_available, reason = _is_hydrus_available(config, use_cache=False) is_available, reason = _is_hydrus_available(config, use_cache=False)
if is_available: if is_available:
logger.info("[Hydrus Health Check] Hydrus API is AVAILABLE") logger.info("[Hydrus Health Check] Hydrus API is AVAILABLE")
return True, None return True, None
else: else:
reason_str = f": {reason}" if reason else "" reason_str = f": {reason}" if reason else ""
logger.warning(f"[Hydrus Health Check] Hydrus API is UNAVAILABLE{reason_str}") logger.warning(f"[Hydrus Health Check] Hydrus API is UNAVAILABLE{reason_str}")
return False, reason return False, reason
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
logger.error(f"[Hydrus Health Check] Error checking Hydrus availability: {error_msg}") logger.error(f"[Hydrus Health Check] Error checking Hydrus availability: {error_msg}")
return False, error_msg return False, error_msg
@@ -88,16 +88,16 @@ def initialize_hydrus_health_check(config: Dict[str, Any]) -> None:
_HYDRUS_CHECK_COMPLETE = True _HYDRUS_CHECK_COMPLETE = True
if is_available: if is_available:
debug("Hydrus: ENABLED - All Hydrus features available", file=sys.stderr) debug("Hydrus: ENABLED - All Hydrus features available", file=sys.stderr)
else: else:
debug(f"⚠️ Hydrus: DISABLED - {reason or 'Connection failed'}", file=sys.stderr) debug(f"Hydrus: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
except Exception as e: except Exception as e:
logger.error(f"[Startup] Failed to initialize Hydrus health check: {e}", exc_info=True) logger.error(f"[Startup] Failed to initialize Hydrus health check: {e}", exc_info=True)
_HYDRUS_AVAILABLE = False _HYDRUS_AVAILABLE = False
_HYDRUS_UNAVAILABLE_REASON = str(e) _HYDRUS_UNAVAILABLE_REASON = str(e)
_HYDRUS_CHECK_COMPLETE = True _HYDRUS_CHECK_COMPLETE = True
debug(f"⚠️ Hydrus: DISABLED - Error during health check: {e}", file=sys.stderr) debug(f"Hydrus: DISABLED - Error during health check: {e}", file=sys.stderr)
def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:

View File

@@ -85,6 +85,10 @@ _PIPELINE_COMMAND_TEXT: str = ""
_PIPELINE_VALUES: Dict[str, Any] = {} _PIPELINE_VALUES: Dict[str, Any] = {}
_PIPELINE_MISSING = object() _PIPELINE_MISSING = object()
# Preserve downstream pipeline stages when a command pauses for @N selection
_PENDING_PIPELINE_TAIL: List[List[str]] = []
_PENDING_PIPELINE_SOURCE: Optional[str] = None
# Global callback to notify UI when library content changes # Global callback to notify UI when library content changes
_UI_LIBRARY_REFRESH_CALLBACK: Optional[Any] = None _UI_LIBRARY_REFRESH_CALLBACK: Optional[Any] = None
@@ -262,11 +266,50 @@ def load_value(key: str, default: Any = None) -> Any:
return current return current
def set_pending_pipeline_tail(stages: Optional[Sequence[Sequence[str]]], source_command: Optional[str] = None) -> None:
"""Store the remaining pipeline stages when execution pauses for @N selection.
Args:
stages: Iterable of pipeline stage token lists
source_command: Command that produced the selection table (for validation)
"""
global _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
try:
pending: List[List[str]] = []
for stage in stages or []:
if isinstance(stage, (list, tuple)):
pending.append([str(token) for token in stage])
_PENDING_PIPELINE_TAIL = pending
clean_source = (source_command or "").strip()
_PENDING_PIPELINE_SOURCE = clean_source if clean_source else None
except Exception:
# Keep existing pending tail on failure
pass
def get_pending_pipeline_tail() -> List[List[str]]:
"""Get a copy of the pending pipeline tail (stages queued after selection)."""
return [list(stage) for stage in _PENDING_PIPELINE_TAIL]
def get_pending_pipeline_source() -> Optional[str]:
"""Get the source command associated with the pending pipeline tail."""
return _PENDING_PIPELINE_SOURCE
def clear_pending_pipeline_tail() -> None:
"""Clear any stored pending pipeline tail."""
global _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
_PENDING_PIPELINE_TAIL = []
_PENDING_PIPELINE_SOURCE = None
def reset() -> None: def reset() -> None:
"""Reset all pipeline state. Called between pipeline executions.""" """Reset all pipeline state. Called between pipeline executions."""
global _PIPE_EMITS, _PIPE_ACTIVE, _PIPE_IS_LAST, _PIPELINE_VALUES global _PIPE_EMITS, _PIPE_ACTIVE, _PIPE_IS_LAST, _PIPELINE_VALUES
global _LAST_PIPELINE_CAPTURE, _PIPELINE_REFRESHED, _PIPELINE_LAST_ITEMS global _LAST_PIPELINE_CAPTURE, _PIPELINE_REFRESHED, _PIPELINE_LAST_ITEMS
global _PIPELINE_COMMAND_TEXT, _LAST_RESULT_SUBJECT, _DISPLAY_SUBJECT global _PIPELINE_COMMAND_TEXT, _LAST_RESULT_SUBJECT, _DISPLAY_SUBJECT
global _PENDING_PIPELINE_TAIL, _PENDING_PIPELINE_SOURCE
_PIPE_EMITS = [] _PIPE_EMITS = []
_PIPE_ACTIVE = False _PIPE_ACTIVE = False
@@ -278,6 +321,8 @@ def reset() -> None:
_PIPELINE_COMMAND_TEXT = "" _PIPELINE_COMMAND_TEXT = ""
_LAST_RESULT_SUBJECT = None _LAST_RESULT_SUBJECT = None
_DISPLAY_SUBJECT = None _DISPLAY_SUBJECT = None
_PENDING_PIPELINE_TAIL = []
_PENDING_PIPELINE_SOURCE = None
def get_emitted_items() -> List[Any]: def get_emitted_items() -> List[Any]:

View File

@@ -118,6 +118,16 @@ class ResultRow:
def add_column(self, name: str, value: Any) -> None: def add_column(self, name: str, value: Any) -> None:
"""Add a column to this row.""" """Add a column to this row."""
str_value = str(value) if value is not None else "" str_value = str(value) if value is not None else ""
# Normalize extension columns globally and cap to 5 characters
if str(name).strip().lower() == "ext":
str_value = str_value.strip().lstrip(".")
for idx, ch in enumerate(str_value):
if not ch.isalnum():
str_value = str_value[:idx]
break
str_value = str_value[:5]
self.columns.append(ResultColumn(name, str_value)) self.columns.append(ResultColumn(name, str_value))
def get_column(self, name: str) -> Optional[str]: def get_column(self, name: str) -> Optional[str]:
@@ -618,48 +628,78 @@ class ResultTable:
for row in self.rows: for row in self.rows:
for col in row.columns: for col in row.columns:
col_name = col.name col_name = col.name
value_width = len(col.value)
if col_name.lower() == "ext":
value_width = min(value_width, 5)
col_widths[col_name] = max( col_widths[col_name] = max(
col_widths.get(col_name, 0), col_widths.get(col_name, 0),
len(col.name), len(col.name),
len(col.value) value_width
) )
# Calculate row number column width # Calculate row number column width
num_width = len(str(len(self.rows))) + 1 # +1 for padding num_width = len(str(len(self.rows))) + 1 # +1 for padding
lines = []
# Add title if present
if self.title:
lines.append("=" * self.title_width)
lines.append(self.title.center(self.title_width))
lines.append("=" * self.title_width)
# Preserve column order
column_names = list(col_widths.keys())
def capped_width(name: str) -> int:
cap = 5 if name.lower() == "ext" else 90
return min(col_widths[name], cap)
widths = [num_width] + [capped_width(name) for name in column_names]
base_inner_width = sum(widths) + (len(widths) - 1) * 3 # account for " | " separators
# Compute final table width (with side walls) to accommodate headers/titles
table_width = base_inner_width + 2 # side walls
if self.title:
table_width = max(table_width, len(self.title) + 2)
if self.header_lines: if self.header_lines:
lines.extend(self.header_lines) table_width = max(table_width, max(len(line) for line in self.header_lines) + 2)
def wrap(text: str) -> str:
"""Wrap content with side walls and pad to table width."""
if len(text) > table_width - 2:
text = text[: table_width - 5] + "..." # keep walls intact
return "|" + text.ljust(table_width - 2) + "|"
lines = []
# Title block
if self.title:
lines.append("|" + "=" * (table_width - 2) + "|")
lines.append(wrap(self.title.center(table_width - 2)))
lines.append("|" + "=" * (table_width - 2) + "|")
# Optional header metadata lines
for meta in self.header_lines:
lines.append(wrap(meta))
# Add header with # column # Add header with # column
header_parts = ["#".ljust(num_width)] header_parts = ["#".ljust(num_width)]
separator_parts = ["-" * num_width] separator_parts = ["-" * num_width]
for col_name in col_widths: for col_name in column_names:
width = min(col_widths[col_name], 90) # Cap column width (increased for expanded titles) width = capped_width(col_name)
header_parts.append(col_name.ljust(width)) header_parts.append(col_name.ljust(width))
separator_parts.append("-" * width) separator_parts.append("-" * width)
lines.append(" | ".join(header_parts)) lines.append(wrap(" | ".join(header_parts)))
lines.append("-+-".join(separator_parts)) lines.append(wrap("-+-".join(separator_parts)))
# Add rows with row numbers # Add rows with row numbers
for row_num, row in enumerate(self.rows, 1): for row_num, row in enumerate(self.rows, 1):
row_parts = [str(row_num).ljust(num_width)] row_parts = [str(row_num).ljust(num_width)]
for col_name in col_widths: for col_name in column_names:
width = min(col_widths[col_name], 90) # Increased cap for expanded titles width = capped_width(col_name)
col_value = row.get_column(col_name) or "" col_value = row.get_column(col_name) or ""
if len(col_value) > width: if len(col_value) > width:
col_value = col_value[:width - 3] + "..." col_value = col_value[: width - 3] + "..."
row_parts.append(col_value.ljust(width)) row_parts.append(col_value.ljust(width))
lines.append(" | ".join(row_parts)) lines.append(wrap(" | ".join(row_parts)))
# Bottom border to close the rectangle
lines.append("|" + "=" * (table_width - 2) + "|")
return "\n".join(lines) return "\n".join(lines)
def format_compact(self) -> str: def format_compact(self) -> str: