This commit is contained in:
nose
2025-12-05 03:42:57 -08:00
parent 5e4df11dbf
commit 5482ee5586
20 changed files with 911 additions and 223 deletions

View File

@@ -91,38 +91,54 @@ def format_cmd_help(cmdlet) -> str:
import os
cmdlet_dir = os.path.dirname(__file__)
for filename in os.listdir(cmdlet_dir):
if (
if not (
filename.endswith(".py")
and not filename.startswith("_")
and filename != "__init__.py"
):
mod_name = filename[:-3]
try:
module = _import_module(f".{mod_name}", __name__)
continue
mod_name = filename[:-3]
# Enforce Powershell-style two-word cmdlet naming (e.g., add_file, get_file)
# Skip native/utility scripts that are not cmdlets (e.g., adjective, worker, matrix, pipe)
if "_" not in mod_name:
continue
try:
module = _import_module(f".{mod_name}", __name__)
# Auto-register based on CMDLET object with exec function
# This allows cmdlets to be fully self-contained in the CMDLET object
if hasattr(module, 'CMDLET'):
cmdlet_obj = module.CMDLET
# Auto-register based on CMDLET object with exec function
# This allows cmdlets to be fully self-contained in the CMDLET object
if hasattr(module, 'CMDLET'):
cmdlet_obj = module.CMDLET
# Get the execution function from the CMDLET object
run_fn = getattr(cmdlet_obj, 'exec', None) if hasattr(cmdlet_obj, 'exec') else None
if callable(run_fn):
# Register main name
if hasattr(cmdlet_obj, 'name') and cmdlet_obj.name:
normalized_name = cmdlet_obj.name.replace('_', '-').lower()
REGISTRY[normalized_name] = run_fn
# Get the execution function from the CMDLET object
run_fn = getattr(cmdlet_obj, 'exec', None) if hasattr(cmdlet_obj, 'exec') else None
if callable(run_fn):
# Register main name
if hasattr(cmdlet_obj, 'name') and cmdlet_obj.name:
normalized_name = cmdlet_obj.name.replace('_', '-').lower()
REGISTRY[normalized_name] = run_fn
# Register all aliases
if hasattr(cmdlet_obj, 'aliases') and cmdlet_obj.aliases:
for alias in cmdlet_obj.aliases:
normalized_alias = alias.replace('_', '-').lower()
REGISTRY[normalized_alias] = run_fn
except Exception as e:
import sys
print(f"Error importing cmdlet '{mod_name}': {e}", file=sys.stderr)
continue
# Register all aliases
if hasattr(cmdlet_obj, 'aliases') and cmdlet_obj.aliases:
for alias in cmdlet_obj.aliases:
normalized_alias = alias.replace('_', '-').lower()
REGISTRY[normalized_alias] = run_fn
except Exception as e:
import sys
print(f"Error importing cmdlet '{mod_name}': {e}", file=sys.stderr)
continue
# Import and register native commands that are not considered cmdlets
try:
from cmdnats import register_native_commands as _register_native_commands
_register_native_commands(REGISTRY)
except Exception:
# Native commands are optional; ignore if unavailable
pass
# Import root-level modules that also register cmdlets
# Note: search_libgen, search_soulseek, and search_debrid are now consolidated into search_provider.py

View File

@@ -267,13 +267,19 @@ def _handle_local_transfer(media_path: Path, destination_root: Path, result: Any
log(f"Warning: Failed to rename file to match title: {e}", file=sys.stderr)
try:
# Ensure filename is the hash when adding to local storage
resolved_hash = _resolve_file_hash(result, sidecar_hash, media_path)
if resolved_hash:
hashed_name = resolved_hash + media_path.suffix
target_path = destination_root / hashed_name
media_path = media_path.rename(target_path) if media_path != target_path else media_path
dest_file = storage["local"].upload(media_path, location=str(destination_root), move=True)
except Exception as exc:
log(f"❌ Failed to move file into {destination_root}: {exc}", file=sys.stderr)
return 1, None
dest_path = Path(dest_file)
file_hash = _resolve_file_hash(result, sidecar_hash, dest_path)
file_hash = _resolve_file_hash(result, resolved_hash, dest_path)
media_kind = _resolve_media_kind(result, dest_path)
# If we have a title tag, keep it. Otherwise, derive from filename.

View File

@@ -18,31 +18,17 @@ from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments, exp
from config import get_local_storage_path
CMDLET = Cmdlet(
name="add-tags",
summary="Add tags to a Hydrus file or write them to a local .tags sidecar.",
usage="add-tags [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
args=[
CmdletArg("-hash", type="string", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
CmdletArg("tags", type="string", required=True, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax.", variadic=True),
],
details=[
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
"- Multiple tags can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- Tags can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
],
)
def _extract_title_tag(tags: List[str]) -> Optional[str]:
"""Return the value of the first title: tag if present."""
for tag in tags:
if isinstance(tag, str) and tag.lower().startswith("title:"):
value = tag.split(":", 1)[1].strip()
if value:
return value
return None
@register(["add-tag", "add-tags"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
@@ -71,11 +57,30 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
return 1
# Get tags from arguments
# Get tags from arguments (or fallback to pipeline payload)
raw_tags = parsed.get("tags", [])
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
# Fallback: if no tags provided explicitly, try to pull from first result payload
if not raw_tags and results:
first = results[0]
payload_tags = None
if isinstance(first, models.PipeObject):
payload_tags = first.extra.get("tags") if isinstance(first.extra, dict) else None
elif isinstance(first, dict):
payload_tags = first.get("tags")
if not payload_tags:
payload_tags = first.get("extra", {}).get("tags") if isinstance(first.get("extra"), dict) else None
# If metadata payload stored tags under nested list, accept directly
if payload_tags is None:
payload_tags = getattr(first, "tags", None)
if payload_tags:
if isinstance(payload_tags, str):
raw_tags = [payload_tags]
elif isinstance(payload_tags, list):
raw_tags = payload_tags
# Handle -list argument (convert to {list} syntax)
list_arg = parsed.get("list")
if list_arg:
@@ -88,6 +93,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
tags_to_add = parse_tag_arguments(raw_tags)
tags_to_add = expand_tag_groups(tags_to_add)
if not tags_to_add:
log("No tags provided to add", file=sys.stderr)
return 1
# Get other flags
hash_override = normalize_hash(parsed.get("hash"))
duplicate_arg = parsed.get("duplicate")
@@ -139,6 +148,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Tags ARE provided - append them to each result and write sidecar files or add to Hydrus
sidecar_count = 0
removed_tags: List[str] = []
for res in results:
# Handle both dict and PipeObject formats
file_path = None
@@ -166,6 +176,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
hydrus_hash = res.get('hydrus_hash') or res.get('hash') or res.get('hash_hex')
if not hydrus_hash and 'extra' in res:
hydrus_hash = res['extra'].get('hydrus_hash') or res['extra'].get('hash') or res['extra'].get('hash_hex')
if not hydrus_hash and file_hash:
hydrus_hash = file_hash
if not storage_source and hydrus_hash and not file_path:
storage_source = 'hydrus'
else:
ctx.emit(res)
continue
@@ -215,6 +229,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Check if this is a namespaced tag (format: "namespace:value")
if ':' in new_tag:
namespace = new_tag.split(':', 1)[0]
# Track removals for Hydrus: delete old tags in same namespace (except identical)
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
removed_tags.extend(to_remove)
# Remove any existing tags with the same namespace
existing_tags = [t for t in existing_tags if not (t.startswith(namespace + ':'))]
@@ -227,6 +244,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
res.extra['tags'] = existing_tags
elif isinstance(res, dict):
res['tags'] = existing_tags
# If a title: tag was added, update the in-memory title so downstream display reflects it immediately
title_value = _extract_title_tag(existing_tags)
if title_value:
if isinstance(res, models.PipeObject):
res.title = title_value
elif isinstance(res, dict):
res['title'] = title_value
# Determine where to add tags: Hydrus, local DB, or sidecar
if storage_source and storage_source.lower() == 'hydrus':
@@ -237,6 +262,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log(f"[add_tags] Adding {len(existing_tags)} tag(s) to Hydrus file: {target_hash}", file=sys.stderr)
hydrus_client = hydrus_wrapper.get_client(config)
hydrus_client.add_tags(target_hash, existing_tags, "my tags")
# Delete old namespace tags we replaced (e.g., previous title:)
if removed_tags:
unique_removed = sorted(set(removed_tags))
hydrus_client.delete_tags(target_hash, unique_removed, "my tags")
log(f"[add_tags] ✓ Tags added to Hydrus", file=sys.stderr)
sidecar_count += 1
except Exception as e:
@@ -274,3 +303,29 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log(f"[add_tags] Processed {len(results)} result(s)", file=sys.stderr)
return 0
CMDLET = Cmdlet(
name="add-tags",
summary="Add tags to a Hydrus file or write them to a local .tags sidecar.",
usage="add-tags [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
args=[
CmdletArg("-hash", type="string", description="Override the Hydrus file hash (SHA256) to target instead of the selected result."),
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
],
details=[
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
"- Multiple tags can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- Tags can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
],
)

View File

@@ -1,148 +0,0 @@
import json
import os
import sys
from typing import List, Dict, Any, Optional, Sequence
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log
from result_table import ResultTable
import pipeline as ctx
ADJECTIVE_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "helper", "adjective.json")
def _load_adjectives() -> Dict[str, List[str]]:
try:
if os.path.exists(ADJECTIVE_FILE):
with open(ADJECTIVE_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
log(f"Error loading adjectives: {e}", file=sys.stderr)
return {}
def _save_adjectives(data: Dict[str, List[str]]) -> bool:
try:
with open(ADJECTIVE_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
return True
except Exception as e:
log(f"Error saving adjectives: {e}", file=sys.stderr)
return False
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
data = _load_adjectives()
# Parse arguments manually first to handle positional args
# We expect: .adjective [category] [tag] [-add] [-delete]
# If no args, list categories
if not args:
table = ResultTable("Adjective Categories")
for i, (category, tags) in enumerate(data.items()):
row = table.add_row()
row.add_column("#", str(i + 1))
row.add_column("Category", category)
row.add_column("Tag Amount", str(len(tags)))
# Selection expands to: .adjective "Category Name"
table.set_row_selection_args(i, [category])
table.set_source_command(".adjective")
ctx.set_last_result_table_overlay(table, list(data.keys()))
ctx.set_current_stage_table(table)
print(table)
return 0
# We have args. First arg is likely category.
category = args[0]
# Check if we are adding a new category (implicit if it doesn't exist)
if category not in data:
# If only category provided, create it
if len(args) == 1:
data[category] = []
_save_adjectives(data)
log(f"Created new category: {category}")
# If more args, we might be trying to add to a non-existent category
elif "-add" in args:
data[category] = []
# Continue to add logic
# Handle operations within category
remaining_args = list(args[1:])
# Check for -add flag
if "-add" in remaining_args:
# .adjective category -add tag
# or .adjective category tag -add
add_idx = remaining_args.index("-add")
# Tag could be before or after
tag = None
if add_idx + 1 < len(remaining_args):
tag = remaining_args[add_idx + 1]
elif add_idx > 0:
tag = remaining_args[add_idx - 1]
if tag:
if tag not in data[category]:
data[category].append(tag)
_save_adjectives(data)
log(f"Added '{tag}' to '{category}'")
else:
log(f"Tag '{tag}' already exists in '{category}'")
else:
log("Error: No tag specified to add")
return 1
# Check for -delete flag
elif "-delete" in remaining_args:
# .adjective category -delete tag
# or .adjective category tag -delete
del_idx = remaining_args.index("-delete")
tag = None
if del_idx + 1 < len(remaining_args):
tag = remaining_args[del_idx + 1]
elif del_idx > 0:
tag = remaining_args[del_idx - 1]
if tag:
if tag in data[category]:
data[category].remove(tag)
_save_adjectives(data)
log(f"Deleted '{tag}' from '{category}'")
else:
log(f"Tag '{tag}' not found in '{category}'")
else:
log("Error: No tag specified to delete")
return 1
# List tags in category (Default action if no flags or after modification)
tags = data.get(category, [])
table = ResultTable(f"Tags in '{category}'")
for i, tag in enumerate(tags):
row = table.add_row()
row.add_column("#", str(i + 1))
row.add_column("Tag", tag)
# Selection expands to: .adjective "Category" "Tag"
# This allows typing @N -delete to delete it
table.set_row_selection_args(i, [category, tag])
table.set_source_command(".adjective")
ctx.set_last_result_table_overlay(table, tags)
ctx.set_current_stage_table(table)
print(table)
return 0
CMDLET = Cmdlet(
name=".adjective",
aliases=["adj"],
summary="Manage adjective categories and tags",
usage=".adjective [category] [-add tag] [-delete tag]",
args=[
CmdletArg(name="category", type="string", description="Category name", required=False),
CmdletArg(name="tag", type="string", description="Tag name", required=False),
CmdletArg(name="add", type="flag", description="Add tag"),
CmdletArg(name="delete", type="flag", description="Delete tag"),
],
exec=_run
)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any, Dict, Sequence
import json
import sys
from . import register
import models
@@ -219,6 +220,12 @@ def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | No
if not tags:
return False
# Safety: block deleting title: without replacement to avoid untitled files
title_tags = [t for t in tags if isinstance(t, str) and t.lower().startswith("title:")]
if title_tags:
log("Cannot delete title: tag without replacement. Use add-tag \"title:new title\" instead.", file=sys.stderr)
return False
if not hash_hex and not file_path:
log("Item does not include a hash or file path")

View File

@@ -41,7 +41,8 @@ from config import resolve_output_dir
from metadata import (
fetch_openlibrary_metadata_tags,
format_playlist_entry,
extract_ytdlp_tags
extract_ytdlp_tags,
build_book_tags,
)
# ============================================================================
@@ -1499,12 +1500,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
metadata = item.get('full_metadata', {}) if isinstance(item.get('full_metadata'), dict) else {}
mirrors = metadata.get('mirrors', {})
book_id = metadata.get('book_id', '')
author = metadata.get('author')
isbn_val = metadata.get('isbn')
year_val = metadata.get('year')
if url:
url_entry = {
'url': str(url),
'mirrors': mirrors, # Alternative mirrors for fallback
'book_id': book_id,
'title': title,
'author': author,
'isbn': isbn_val,
'year': year_val,
}
urls_to_download.append(url_entry)
debug(f"[search-result] LibGen: '{title}'")
@@ -1700,12 +1708,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
metadata = getattr(item, 'full_metadata', {}) if isinstance(getattr(item, 'full_metadata', None), dict) else {}
mirrors = metadata.get('mirrors', {})
book_id = metadata.get('book_id', '')
author = metadata.get('author')
isbn_val = metadata.get('isbn')
year_val = metadata.get('year')
if url:
url_entry = {
'url': str(url),
'mirrors': mirrors, # Alternative mirrors for fallback
'book_id': book_id,
'title': title,
'author': author,
'isbn': isbn_val,
'year': year_val,
}
urls_to_download.append(url_entry)
else:
@@ -2177,6 +2192,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
primary_url = url.get('url')
mirrors_dict = url.get('mirrors', {})
book_id = url.get('book_id', '')
title_val = url.get('title')
author_val = url.get('author')
isbn_val = url.get('isbn')
year_val = url.get('year')
if not primary_url:
debug(f"Skipping libgen entry: no primary URL")
@@ -2219,39 +2238,82 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Use libgen_service's download_from_mirror for proper libgen handling
from helper.libgen_service import download_from_mirror
# Generate filename from book_id and title
safe_title = "".join(c for c in str(title or "book") if c.isalnum() or c in (' ', '.', '-'))[:100]
file_path = final_output_dir / f"{safe_title}_{book_id}.pdf"
progress_bar = models.ProgressBar()
progress_start = time.time()
last_update = [progress_start]
progress_bytes = [0]
progress_total = [0]
def _libgen_progress(downloaded: int, total: int) -> None:
progress_bytes[0] = downloaded
progress_total[0] = total
now = time.time()
if total > 0 and now - last_update[0] >= 0.5:
percent = (downloaded / total) * 100
elapsed = max(now - progress_start, 1e-6)
speed = downloaded / elapsed if elapsed > 0 else 0
remaining = max(total - downloaded, 0)
eta = remaining / speed if speed > 0 else 0
minutes, seconds = divmod(int(eta), 60)
hours, minutes = divmod(minutes, 60)
eta_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
speed_str = f"{progress_bar.format_bytes(speed)}/s"
progress_line = progress_bar.format_progress(
percent_str=f"{percent:.1f}%",
downloaded=downloaded,
total=total,
speed_str=speed_str,
eta_str=eta_str,
)
debug(f" {progress_line}")
last_update[0] = now
# Attempt download using libgen's native function
success = download_from_mirror(
success, downloaded_path = download_from_mirror(
mirror_url=mirror_url,
output_path=file_path,
log_info=lambda msg: debug(f" {msg}"),
log_error=lambda msg: debug(f"{msg}")
log_error=lambda msg: debug(f"{msg}"),
progress_callback=_libgen_progress,
)
if success and file_path.exists():
final_path = Path(downloaded_path) if downloaded_path else file_path
if success and final_path.exists():
downloaded = progress_bytes[0] or final_path.stat().st_size
elapsed = time.time() - progress_start
avg_speed = downloaded / elapsed if elapsed > 0 else 0
debug(f" ✓ Downloaded in {elapsed:.1f}s at {progress_bar.format_bytes(avg_speed)}/s")
debug(f" ✓ Downloaded successfully from mirror #{mirror_idx}")
successful_mirror = mirror_url
download_succeeded = True
# Emit result for downstream cmdlets
file_hash = _compute_file_hash(file_path)
emit_tags = ['libgen', 'book']
file_hash = _compute_file_hash(final_path)
emit_tags = build_book_tags(
title=title_val or title,
author=author_val,
isbn=isbn_val,
year=year_val,
source='libgen',
extra=[f"libgen_id:{book_id}"] if book_id else None,
)
pipe_obj = create_pipe_object_result(
source='libgen',
identifier=book_id,
file_path=str(file_path),
file_path=str(final_path),
cmdlet_name='download-data',
file_hash=file_hash,
tags=emit_tags,
source_url=successful_mirror
)
pipeline_context.emit(pipe_obj)
downloaded_files.append(str(file_path))
downloaded_files.append(str(final_path))
exit_code = 0
break # Success, stop trying mirrors
@@ -2643,38 +2705,61 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Let's try to get metadata to make a good filename
filename = "libgen_download.bin"
title_from_results = None
author_from_results = None
year_from_results = None
if libgen_id and results:
title = results[0].get("title", "book")
title_from_results = results[0].get("title")
author_from_results = results[0].get("author")
year_from_results = results[0].get("year")
ext = results[0].get("extension", "pdf")
# Sanitize filename
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()
safe_title = "".join(c for c in (title_from_results or "book") if c.isalnum() or c in (' ', '-', '_')).strip()
filename = f"{safe_title}.{ext}"
elif "series.php" in url:
filename = f"series_{re.search(r'id=(\d+)', url).group(1) if re.search(r'id=(\d+)', url) else 'unknown'}.pdf"
output_path = final_output_dir / filename
if download_from_mirror(url, output_path, log_info=debug, log_error=log):
debug(f"✓ LibGen download successful: {output_path}")
success, downloaded_path = download_from_mirror(
url,
output_path,
log_info=debug,
log_error=log,
)
final_file = Path(downloaded_path) if downloaded_path else output_path
if success and final_file.exists():
debug(f"✓ LibGen download successful: {final_file}")
# Create a result object
info = {
"id": libgen_id or "libgen",
"title": filename,
"webpage_url": url,
"ext": output_path.suffix.lstrip("."),
"ext": final_file.suffix.lstrip("."),
}
emit_tags = build_book_tags(
title=title_from_results or filename,
author=author_from_results,
year=year_from_results,
source="libgen",
extra=[f"libgen_id:{libgen_id}"] if libgen_id else None,
)
file_hash = _compute_file_hash(final_file)
# Emit result
pipeline_context.emit(create_pipe_object_result(
source="libgen",
identifier=libgen_id or "libgen",
file_path=str(output_path),
file_path=str(final_file),
cmdlet_name="download-data",
title=filename,
file_hash=file_hash,
tags=emit_tags,
extra=info
))
downloaded_files.append(str(output_path))
downloaded_files.append(str(final_file))
continue
else:
debug("⚠ LibGen specialized download failed, falling back to generic downloader...")

View File

@@ -316,6 +316,12 @@ def _play_in_mpv(file_url: str, file_title: str, is_stream: bool = False, header
return False
# Backward-compatible alias for modules expecting the old IPC helper name.
def _get_fixed_ipc_pipe() -> str:
"""Return the shared MPV IPC pipe path (compat shim)."""
return get_ipc_pipe_path()
def _handle_search_result(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Handle a file from search-file results using FileStorage backend."""
try:

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import sys
from helper.logger import log
from helper.metadata_search import get_metadata_provider
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -1015,33 +1016,82 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
scrape_url = parsed_args.get("scrape")
scrape_requested = scrape_url is not None
# Handle URL scraping mode
# Handle URL or provider scraping mode
if scrape_requested and scrape_url:
import json as json_module
# Don't print debug message - output should be JSON only for programmatic consumption
# logger.debug(f"Scraping URL: {scrape_url}")
title, tags, formats, playlist_items = _scrape_url_metadata(scrape_url)
if scrape_url.startswith("http://") or scrape_url.startswith("https://"):
# URL scraping (existing behavior)
title, tags, formats, playlist_items = _scrape_url_metadata(scrape_url)
if not tags:
log("No tags extracted from URL", file=sys.stderr)
return 1
output = {
"title": title,
"tags": tags,
"formats": [(label, fmt_id) for label, fmt_id in formats],
"playlist_items": playlist_items,
}
print(json_module.dumps(output, ensure_ascii=False))
return 0
if not tags:
log("No tags extracted from URL", file=sys.stderr)
# Provider scraping (e.g., itunes)
provider = get_metadata_provider(scrape_url, config)
if provider is None:
log(f"Unknown metadata provider: {scrape_url}", file=sys.stderr)
return 1
# Build result object
# result_obj = TagItem("url_scrape", tag_index=0, hash_hex=None, source="url", service_name=None)
# result_obj.title = title or "URL Content"
# Determine query from title on the result or filename
title_hint = get_field(result, "title", None) or get_field(result, "name", None)
if not title_hint:
file_path = get_field(result, "path", None) or get_field(result, "filename", None)
if file_path:
title_hint = Path(str(file_path)).stem
# Emit tags as JSON for pipeline consumption (output should be pure JSON on stdout)
output = {
"title": title,
"tags": tags,
"formats": [(label, fmt_id) for label, fmt_id in formats],
"playlist_items": playlist_items,
}
if not title_hint:
log("No title available to search for metadata", file=sys.stderr)
return 1
# Use print() directly to stdout for JSON output (NOT log() which adds prefix)
# This ensures the output is capturable by the download modal and other pipelines
# The modal filters for lines starting with '{' so the prefix breaks parsing
print(json_module.dumps(output, ensure_ascii=False))
items = provider.search(title_hint, limit=10)
if not items:
log("No metadata results found", file=sys.stderr)
return 1
from result_table import ResultTable
table = ResultTable(f"Metadata: {provider.name}")
table.set_source_command("get-tag", [])
selection_payload = []
hash_for_payload = normalize_hash(hash_override) or normalize_hash(get_field(result, "hash_hex", None))
for idx, item in enumerate(items):
tags = provider.to_tags(item)
row = table.add_row()
row.add_column("Title", item.get("title", ""))
row.add_column("Artist", item.get("artist", ""))
row.add_column("Album", item.get("album", ""))
row.add_column("Year", item.get("year", ""))
payload = {
"tags": tags,
"provider": provider.name,
"title": item.get("title"),
"artist": item.get("artist"),
"album": item.get("album"),
"year": item.get("year"),
"extra": {
"tags": tags,
"provider": provider.name,
"hydrus_hash": hash_for_payload,
"storage_source": get_field(result, "source", None) or get_field(result, "origin", None),
},
"file_hash": hash_for_payload,
}
selection_payload.append(payload)
table.set_row_selection_args(idx, [str(idx + 1)])
ctx.set_last_result_table_overlay(table, selection_payload)
ctx.set_current_stage_table(table)
# Preserve items for @ selection and downstream pipes without emitting duplicates
ctx.set_last_result_items_only(selection_payload)
print(table)
return 0
# If -scrape was requested but no URL, that's an error
@@ -1178,7 +1228,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
CMDLET = Cmdlet(
name="get-tag",
summary="Get tags from Hydrus or local sidecar metadata",
usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url>]",
usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url|provider>]",
aliases=["tags"],
args=[
SharedArgs.HASH,
@@ -1197,7 +1247,7 @@ CMDLET = Cmdlet(
CmdletArg(
name="-scrape",
type="string",
description="Scrape metadata from URL (returns tags as JSON)",
description="Scrape metadata from URL or provider name (returns tags as JSON or table)",
required=False
)
]

View File

@@ -1,103 +0,0 @@
from typing import Any, Dict, Sequence, List
import sys
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug
from result_table import ResultTable
from helper.file_storage import MatrixStorageBackend
from config import save_config, load_config
import pipeline as ctx
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
parsed = parse_cmdlet_args(args, CMDLET)
# Initialize backend
backend = MatrixStorageBackend()
# Get current default room
matrix_conf = config.get('storage', {}).get('matrix', {})
current_room_id = matrix_conf.get('room_id')
# Fetch rooms
debug("Fetching joined rooms from Matrix...")
rooms = backend.list_rooms(config)
if not rooms:
debug("No joined rooms found or Matrix not configured.")
return 1
# Handle selection if provided
selection = parsed.get("selection")
if selection:
new_room_id = None
selected_room_name = None
# Try as index (1-based)
try:
idx = int(selection) - 1
if 0 <= idx < len(rooms):
selected_room = rooms[idx]
new_room_id = selected_room['id']
selected_room_name = selected_room['name']
except ValueError:
# Try as Room ID
for room in rooms:
if room['id'] == selection:
new_room_id = selection
selected_room_name = room['name']
break
if new_room_id:
# Update config
# Load fresh config from disk to avoid saving runtime objects (like WorkerManager)
disk_config = load_config()
if 'storage' not in disk_config: disk_config['storage'] = {}
if 'matrix' not in disk_config['storage']: disk_config['storage']['matrix'] = {}
disk_config['storage']['matrix']['room_id'] = new_room_id
save_config(disk_config)
debug(f"Default Matrix room set to: {selected_room_name} ({new_room_id})")
current_room_id = new_room_id
else:
debug(f"Invalid selection: {selection}")
return 1
# Display table
table = ResultTable("Matrix Rooms")
for i, room in enumerate(rooms):
is_default = (room['id'] == current_room_id)
row = table.add_row()
row.add_column("Default", "*" if is_default else "")
row.add_column("Name", room['name'])
row.add_column("ID", room['id'])
# Set selection args so user can type @N to select
# This will run .matrix N
table.set_row_selection_args(i, [str(i + 1)])
table.set_source_command(".matrix")
# Register results
ctx.set_last_result_table_overlay(table, rooms)
ctx.set_current_stage_table(table)
print(table)
return 0
CMDLET = Cmdlet(
name=".matrix",
aliases=["matrix", "rooms"],
summary="List and select default Matrix room",
usage=".matrix [selection]",
args=[
CmdletArg(
name="selection",
type="string",
description="Index or ID of the room to set as default",
required=False
)
],
exec=_run
)

View File

@@ -1,690 +0,0 @@
from typing import Any, Dict, Sequence, List, Optional
import sys
import json
import platform
import socket
import re
import subprocess
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log, debug
from result_table import ResultTable
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
import pipeline as ctx
from helper.download import is_url_supported_by_ytdlp
from helper.local_library import LocalLibrarySearchOptimizer
from config import get_local_storage_path
from hydrus_health_check import get_cookies_file_path
def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]:
"""Send a command to the MPV IPC pipe and return the response."""
try:
ipc_pipe = get_ipc_pipe_path()
client = MPVIPCClient(socket_path=ipc_pipe)
if not client.connect():
return None # MPV not running
response = client.send_command(command)
client.disconnect()
return response
except Exception as e:
if not silent:
debug(f"IPC Error: {e}", file=sys.stderr)
return None
def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
"""Get the current playlist from MPV. Returns None if MPV is not running."""
cmd = {"command": ["get_property", "playlist"], "request_id": 100}
resp = _send_ipc_command(cmd, silent=silent)
if resp is None:
return None
if resp.get("error") == "success":
return resp.get("data", [])
return []
def _extract_title_from_item(item: Dict[str, Any]) -> str:
"""Extract a clean title from an MPV playlist item, handling memory:// M3U hacks."""
title = item.get("title")
filename = item.get("filename") or ""
# Special handling for memory:// M3U playlists (used to pass titles via IPC)
if "memory://" in filename and "#EXTINF:" in filename:
try:
# Extract title from #EXTINF:-1,Title
# Use regex to find title between #EXTINF:-1, and newline
match = re.search(r"#EXTINF:-1,(.*?)(?:\n|\r|$)", filename)
if match:
extracted_title = match.group(1).strip()
if not title or title == "memory://":
title = extracted_title
# If we still don't have a title, try to find the URL in the M3U content
if not title:
lines = filename.splitlines()
for line in lines:
line = line.strip()
if line and not line.startswith('#') and not line.startswith('memory://'):
# Found the URL, use it as title
return line
except Exception:
pass
return title or filename or "Unknown"
def _ensure_ytdl_cookies() -> None:
"""Ensure yt-dlp options are set correctly for this session."""
from pathlib import Path
cookies_path = get_cookies_file_path()
if cookies_path:
# Check if file exists and has content (use forward slashes for path checking)
check_path = cookies_path.replace('\\', '/')
file_obj = Path(cookies_path)
if file_obj.exists():
file_size = file_obj.stat().st_size
debug(f"Cookies file verified: {check_path} ({file_size} bytes)")
else:
debug(f"WARNING: Cookies file does not exist: {check_path}", file=sys.stderr)
else:
debug("No cookies file configured")
def _monitor_mpv_logs(duration: float = 3.0) -> None:
"""Monitor MPV logs for a short duration to capture errors."""
try:
client = MPVIPCClient()
if not client.connect():
debug("Failed to connect to MPV for log monitoring", file=sys.stderr)
return
# Request log messages
client.send_command({"command": ["request_log_messages", "warn"]})
import time
start_time = time.time()
while time.time() - start_time < duration:
# We need to read raw lines from the socket
if client.is_windows:
try:
line = client.sock.readline()
if line:
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
time.sleep(0.05)
client.disconnect()
except Exception:
pass
def _queue_items(items: List[Any], clear_first: bool = False) -> bool:
"""Queue items to MPV, starting it if necessary.
Args:
items: List of items to queue
clear_first: If True, the first item will replace the current playlist
Returns:
True if MPV was started, False if items were queued via IPC.
"""
# Just verify cookies are configured, don't try to set via IPC
_ensure_ytdl_cookies()
for i, item in enumerate(items):
# Extract URL/Path
target = None
title = None
if isinstance(item, dict):
target = item.get("target") or item.get("url") or item.get("path") or item.get("filename")
title = item.get("title") or item.get("name")
elif hasattr(item, "target"):
target = item.target
title = getattr(item, "title", None)
elif isinstance(item, str):
target = item
if target:
# Check if it's a yt-dlp supported URL
is_ytdlp = False
if target.startswith("http") and is_url_supported_by_ytdlp(target):
is_ytdlp = True
# Use memory:// M3U hack to pass title to MPV
# Skip for yt-dlp URLs to ensure proper handling
if title and not is_ytdlp:
# Sanitize title for M3U (remove newlines)
safe_title = title.replace('\n', ' ').replace('\r', '')
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
target_to_send = f"memory://{m3u_content}"
else:
target_to_send = target
mode = "append"
if clear_first and i == 0:
mode = "replace"
cmd = {"command": ["loadfile", target_to_send, mode], "request_id": 200}
resp = _send_ipc_command(cmd)
if resp is None:
# MPV not running (or died)
# Start MPV with remaining items
_start_mpv(items[i:])
return True
elif resp.get("error") == "success":
# Also set property for good measure
if title:
title_cmd = {"command": ["set_property", "force-media-title", title], "request_id": 201}
_send_ipc_command(title_cmd)
debug(f"Queued: {title or target}")
else:
error_msg = str(resp.get('error'))
debug(f"Failed to queue item: {error_msg}", file=sys.stderr)
return False
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Manage and play items in the MPV playlist via IPC."""
parsed = parse_cmdlet_args(args, CMDLET)
# Initialize mpv_started flag
mpv_started = False
# Handle positional index argument if provided
index_arg = parsed.get("index")
url_arg = parsed.get("url")
# If index_arg is provided but is not an integer, treat it as a URL
# This allows .pipe "http://..." without -url flag
if index_arg is not None:
try:
int(index_arg)
except ValueError:
# Not an integer, treat as URL if url_arg is not set
if not url_arg:
url_arg = index_arg
index_arg = None
clear_mode = parsed.get("clear")
list_mode = parsed.get("list")
play_mode = parsed.get("play")
pause_mode = parsed.get("pause")
save_mode = parsed.get("save")
load_mode = parsed.get("load")
current_mode = parsed.get("current")
# Handle --current flag: emit currently playing item to pipeline
if current_mode:
items = _get_playlist()
if items is None:
debug("MPV is not running or not accessible.", file=sys.stderr)
return 1
# Find the currently playing item
current_item = None
for item in items:
if item.get("current", False):
current_item = item
break
if current_item is None:
debug("No item is currently playing.", file=sys.stderr)
return 1
# Build result object with file info
title = _extract_title_from_item(current_item)
filename = current_item.get("filename", "")
# Emit the current item to pipeline
result_obj = {
'file_path': filename,
'title': title,
'cmdlet_name': '.pipe',
'source': 'pipe',
'__pipe_index': items.index(current_item),
}
ctx.emit(result_obj)
debug(f"Emitted current item: {title}")
return 0
# Handle URL queuing
mpv_started = False
if url_arg:
mpv_started = _queue_items([url_arg])
# Auto-play the URL when it's queued via .pipe "url" (without explicit flags)
# unless other flags are present
if not (clear_mode or play_mode or pause_mode or save_mode or load_mode):
if mpv_started:
# MPV was just started, wait a moment for it to be ready, then play first item
import time
time.sleep(0.5)
index_arg = "1" # 1-based index for first item
play_mode = True
else:
# MPV was already running, get playlist and play the newly added item
playlist = _get_playlist(silent=True)
if playlist and len(playlist) > 0:
# Auto-play the last item in the playlist (the one we just added)
# Use 1-based indexing
index_arg = str(len(playlist))
play_mode = True
else:
# Fallback: just list the playlist if we can't determine index
list_mode = True
# Handle Save Playlist
if save_mode:
playlist_name = index_arg or f"Playlist {subprocess.check_output(['date', '/t'], shell=True).decode().strip()}"
# If index_arg was used for name, clear it so it doesn't trigger index logic
if index_arg:
index_arg = None
items = _get_playlist()
if not items:
debug("Cannot save: MPV playlist is empty or MPV is not running.")
return 1
# Clean up items for saving (remove current flag, etc)
clean_items = []
for item in items:
# If title was extracted from memory://, we should probably save the original filename
# if it's a URL, or reconstruct a clean object.
# Actually, _extract_title_from_item handles the display title.
# But for playback, we need the 'filename' (which might be memory://...)
# If we save 'memory://...', it will work when loaded back.
clean_items.append(item)
# Use config from context or load it
config_data = config if config else {}
storage_path = get_local_storage_path(config_data)
if not storage_path:
debug("Local storage path not configured.")
return 1
with LocalLibrarySearchOptimizer(storage_path) as db:
if db.save_playlist(playlist_name, clean_items):
debug(f"Playlist saved as '{playlist_name}'")
return 0
else:
debug(f"Failed to save playlist '{playlist_name}'")
return 1
# Handle Load Playlist
current_playlist_name = None
if load_mode:
# Use config from context or load it
config_data = config if config else {}
storage_path = get_local_storage_path(config_data)
if not storage_path:
debug("Local storage path not configured.")
return 1
with LocalLibrarySearchOptimizer(storage_path) as db:
if index_arg:
try:
pl_id = int(index_arg)
# Handle Delete Playlist (if -clear is also passed)
if clear_mode:
if db.delete_playlist(pl_id):
debug(f"Playlist ID {pl_id} deleted.")
# Clear index_arg so we fall through to list mode and show updated list
index_arg = None
# Don't return, let it list the remaining playlists
else:
debug(f"Failed to delete playlist ID {pl_id}.")
return 1
else:
# Handle Load Playlist
result = db.get_playlist_by_id(pl_id)
if result is None:
debug(f"Playlist ID {pl_id} not found.")
return 1
name, items = result
current_playlist_name = name
# Queue items (replacing current playlist)
if items:
_queue_items(items, clear_first=True)
else:
# Empty playlist, just clear
_send_ipc_command({"command": ["playlist-clear"]}, silent=True)
# Switch to list mode to show the result
list_mode = True
index_arg = None
# Fall through to list logic
except ValueError:
debug(f"Invalid playlist ID: {index_arg}")
return 1
# If we deleted or didn't have an index, list playlists
if not index_arg:
playlists = db.get_playlists()
if not playlists:
debug("No saved playlists found.")
return 0
table = ResultTable("Saved Playlists")
for i, pl in enumerate(playlists):
item_count = len(pl.get('items', []))
row = table.add_row()
# row.add_column("ID", str(pl['id'])) # Hidden as per user request
row.add_column("Name", pl['name'])
row.add_column("Items", str(item_count))
row.add_column("Updated", pl['updated_at'])
# Set the playlist items as the result object for this row
# When user selects @N, they get the list of items
# We also set the source command to .pipe -load <ID> so it loads it
table.set_row_selection_args(i, ["-load", str(pl['id'])])
table.set_source_command(".pipe")
# Register results
ctx.set_last_result_table_overlay(table, [p['items'] for p in playlists])
ctx.set_current_stage_table(table)
print(table)
return 0
# Handle Play/Pause commands (but skip if we have index_arg to play a specific item)
if play_mode and index_arg is None:
cmd = {"command": ["set_property", "pause", False], "request_id": 103}
resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success":
debug("Resumed playback")
return 0
else:
debug("Failed to resume playback (MPV not running?)", file=sys.stderr)
return 1
if pause_mode:
cmd = {"command": ["set_property", "pause", True], "request_id": 104}
resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success":
debug("Paused playback")
return 0
else:
debug("Failed to pause playback (MPV not running?)", file=sys.stderr)
return 1
# Handle Clear All command (no index provided)
if clear_mode and index_arg is None:
cmd = {"command": ["playlist-clear"], "request_id": 105}
resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success":
debug("Playlist cleared")
return 0
else:
debug("Failed to clear playlist (MPV not running?)", file=sys.stderr)
return 1
# Handle piped input (add to playlist)
# Skip adding if -list is specified (user just wants to see current playlist)
if result and not list_mode and not url_arg:
# If result is a list of items, add them to playlist
items_to_add = []
if isinstance(result, list):
items_to_add = result
elif isinstance(result, dict):
items_to_add = [result]
if _queue_items(items_to_add):
mpv_started = True
if items_to_add:
# If we added items, we might want to play the first one if nothing is playing?
# For now, just list the playlist
pass
# Get playlist from MPV
items = _get_playlist()
if items is None:
if mpv_started:
# MPV was just started, retry getting playlist after a brief delay
import time
time.sleep(0.3)
items = _get_playlist(silent=True)
if items is None:
# Still can't connect, but MPV is starting
debug("MPV is starting up...")
return 0
else:
debug("MPV is not running. Starting new instance...")
_start_mpv([])
return 0
if not items:
debug("MPV playlist is empty.")
return 0
# If index is provided, perform action (Play or Clear)
if index_arg is not None:
try:
# Handle 1-based index
idx = int(index_arg) - 1
if idx < 0 or idx >= len(items):
debug(f"Index {index_arg} out of range (1-{len(items)}).")
return 1
item = items[idx]
title = _extract_title_from_item(item)
if clear_mode:
# Remove item
cmd = {"command": ["playlist-remove", idx], "request_id": 101}
resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success":
debug(f"Removed: {title}")
# Refresh items for listing
items = _get_playlist() or []
list_mode = True
index_arg = None
else:
debug(f"Failed to remove item: {resp.get('error') if resp else 'No response'}")
return 1
else:
# Play item
cmd = {"command": ["playlist-play-index", idx], "request_id": 102}
resp = _send_ipc_command(cmd)
if resp and resp.get("error") == "success":
# Ensure playback starts (unpause)
unpause_cmd = {"command": ["set_property", "pause", False], "request_id": 103}
_send_ipc_command(unpause_cmd)
debug(f"Playing: {title}")
# Monitor logs briefly for errors (e.g. ytdl failures)
_monitor_mpv_logs(3.0)
return 0
else:
debug(f"Failed to play item: {resp.get('error') if resp else 'No response'}")
return 1
except ValueError:
debug(f"Invalid index: {index_arg}")
return 1
# List items (Default action or after clear)
if list_mode or (index_arg is None and not url_arg):
if not items:
debug("MPV playlist is empty.")
return 0
# Use the loaded playlist name if available, otherwise default
# Note: current_playlist_name is defined in the load_mode block if a playlist was loaded
try:
table_title = current_playlist_name or "MPV Playlist"
except NameError:
table_title = "MPV Playlist"
table = ResultTable(table_title)
for i, item in enumerate(items):
is_current = item.get("current", False)
title = _extract_title_from_item(item)
# Truncate if too long
if len(title) > 80:
title = title[:77] + "..."
row = table.add_row()
row.add_column("Current", "*" if is_current else "")
row.add_column("Title", title)
table.set_row_selection_args(i, [str(i + 1)])
table.set_source_command(".pipe")
# Register results with pipeline context so @N selection works
ctx.set_last_result_table_overlay(table, items)
ctx.set_current_stage_table(table)
print(table)
return 0
def _start_mpv(items: List[Any]) -> None:
"""Start MPV with a list of items."""
import subprocess
import time as _time_module
# Kill any existing MPV processes to ensure clean start
try:
subprocess.run(['taskkill', '/IM', 'mpv.exe', '/F'],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=2)
_time_module.sleep(0.5) # Wait for process to die
except Exception:
pass
ipc_pipe = get_ipc_pipe_path()
# Start MPV in idle mode with IPC server
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
# Use cookies.txt if available, otherwise fallback to browser cookies
cookies_path = get_cookies_file_path()
if cookies_path:
# yt-dlp on Windows needs forward slashes OR properly escaped backslashes
# Using forward slashes is more reliable across systems
cookies_path_normalized = cookies_path.replace('\\', '/')
debug(f"Starting MPV with cookies file: {cookies_path_normalized}")
# yt-dlp expects the cookies option with file path
cmd.append(f'--ytdl-raw-options=cookies={cookies_path_normalized}')
else:
# Use cookies from browser (Chrome) to handle age-restricted content
debug("Starting MPV with browser cookies: chrome")
cmd.append('--ytdl-raw-options=cookies-from-browser=chrome')
try:
kwargs = {}
if platform.system() == 'Windows':
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
# Log the complete MPV command being executed
debug(f"DEBUG: Full MPV command: {' '.join(cmd)}")
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
debug(f"Started MPV process")
# Wait for IPC pipe to be ready
import time
max_retries = 20
for i in range(max_retries):
time.sleep(0.2)
client = MPVIPCClient(socket_path=ipc_pipe)
if client.connect():
client.disconnect()
break
else:
debug("Timed out waiting for MPV IPC connection", file=sys.stderr)
return
# Queue items via IPC
if items:
_queue_items(items)
except Exception as e:
debug(f"Error starting MPV: {e}", file=sys.stderr)
CMDLET = Cmdlet(
name=".pipe",
aliases=["pipe", "playlist", "queue", "ls-pipe"],
summary="Manage and play items in the MPV playlist via IPC",
usage=".pipe [index|url] [-current] [-clear] [-list] [-url URL]",
args=[
CmdletArg(
name="index",
type="string", # Changed to string to allow URL detection
description="Index of item to play/clear, or URL to queue",
required=False
),
CmdletArg(
name="url",
type="string",
description="URL to queue",
required=False
),
CmdletArg(
name="clear",
type="flag",
description="Remove the selected item, or clear entire playlist if no index provided"
),
CmdletArg(
name="list",
type="flag",
description="List items (default)"
),
CmdletArg(
name="play",
type="flag",
description="Resume playback"
),
CmdletArg(
name="pause",
type="flag",
description="Pause playback"
),
CmdletArg(
name="save",
type="flag",
description="Save current playlist to database"
),
CmdletArg(
name="load",
type="flag",
description="List saved playlists"
),
CmdletArg(
name="current",
type="flag",
description="Emit the currently playing item to pipeline for further processing"
),
],
exec=_run
)

View File

@@ -1,320 +0,0 @@
"""Worker cmdlet: Display workers table in ResultTable format."""
from __future__ import annotations
from typing import Any, Dict, Sequence, List
import json
import sys
from datetime import datetime, timezone
from . import register
from ._shared import Cmdlet, CmdletArg
import pipeline as ctx
from helper.logger import log
from config import get_local_storage_path
CMDLET = Cmdlet(
name=".worker",
summary="Display workers table in result table format.",
usage=".worker [status] [-limit N] [@N]",
args=[
CmdletArg("status", description="Filter by status: running, completed, error (default: all)"),
CmdletArg("limit", type="integer", description="Limit results (default: 100)"),
CmdletArg("@N", description="Select worker by index (1-based) and display full logs"),
],
details=[
"- Shows all background worker tasks and their output",
"- Can filter by status: running, completed, error",
"- Search result stdout is captured from each worker",
"- Use @N to select a specific worker by index and display its full logs",
"Examples:",
".worker # Show all workers",
".worker running # Show running workers only",
".worker completed -limit 50 # Show 50 most recent completed workers",
".worker @3 # Show full logs for the 3rd worker",
".worker running @2 # Show full logs for the 2nd running worker",
],
)
@register([".worker", "worker", "workers"])
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Display workers table or show detailed logs for a specific worker."""
args_list = [str(arg) for arg in (args or [])]
selection_indices = ctx.get_last_selection()
selection_requested = bool(selection_indices) and isinstance(result, list) and len(result) > 0
# Parse arguments for list view
status_filter: str | None = None
limit = 100
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:
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 {})
if not library_root:
log("No library root configured", file=sys.stderr)
return 1
try:
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:
return _render_worker_selection(db, result)
return _render_worker_list(db, status_filter, 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 _render_worker_list(db, status_filter: str | None, limit: int) -> int:
workers = db.get_all_workers(limit=limit)
if status_filter:
workers = [w for w in workers if str(w.get("status", "")).lower() == status_filter]
if not workers:
log("No workers found", file=sys.stderr)
return 0
for worker in workers:
started = worker.get("started_at", "")
ended = worker.get("completed_at", worker.get("last_updated", ""))
date_str = _extract_date(started)
start_time = _format_event_timestamp(started)
end_time = _format_event_timestamp(ended)
item = {
"columns": [
("Status", worker.get("status", "")),
("Pipe", _summarize_pipe(worker.get("pipe"))),
("Date", date_str),
("Start Time", start_time),
("End Time", end_time),
],
"__worker_metadata": worker,
"_selection_args": ["-id", worker.get("worker_id")]
}
ctx.emit(item)
return 0
def _render_worker_selection(db, selected_items: Any) -> int:
if not isinstance(selected_items, list):
log("Selection payload missing", file=sys.stderr)
return 1
emitted = False
for item in selected_items:
worker = _resolve_worker_record(db, item)
if not worker:
continue
events = []
try:
events = db.get_worker_events(worker.get("worker_id")) if hasattr(db, "get_worker_events") else []
except Exception:
events = []
_emit_worker_detail(worker, events)
emitted = True
if not emitted:
log("Selected rows no longer exist", file=sys.stderr)
return 1
return 0
def _resolve_worker_record(db, payload: Any) -> Dict[str, Any] | None:
if not isinstance(payload, dict):
return None
worker_data = payload.get("__worker_metadata")
worker_id = None
if isinstance(worker_data, dict):
worker_id = worker_data.get("worker_id")
else:
worker_id = payload.get("worker_id")
worker_data = None
if worker_id:
fresh = db.get_worker(worker_id)
if fresh:
return fresh
return worker_data if isinstance(worker_data, dict) else 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 ""
# Try to parse lines if they follow the standard log format
# Format: YYYY-MM-DD HH:MM:SS - name - level - message
lines = stdout_content.splitlines()
for line in lines:
line = line.strip()
if not line:
continue
# Default values
timestamp = ""
level = "INFO"
message = line
# Try to parse standard format
try:
parts = line.split(" - ", 3)
if len(parts) >= 4:
# Full format
ts_str, _, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
elif len(parts) == 3:
# Missing name or level
ts_str, lvl, msg = parts
timestamp = _format_event_timestamp(ts_str)
level = lvl
message = msg
except Exception:
pass
item = {
"columns": [
("Time", timestamp),
("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:
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..."
def _format_event_timestamp(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%H:%M:%S")
if not raw_timestamp:
return "--:--:--"
text = str(raw_timestamp)
if "T" in text:
time_part = text.split("T", 1)[1]
elif " " in text:
time_part = text.split(" ", 1)[1]
else:
time_part = text
return time_part[:8] if len(time_part) >= 8 else time_part
def _parse_to_local(timestamp_str: Any) -> datetime | None:
if not timestamp_str:
return None
text = str(timestamp_str).strip()
if not text:
return None
try:
# Check for T separator (Python isoformat - Local time)
if 'T' in text:
return datetime.fromisoformat(text)
# Check for space separator (SQLite CURRENT_TIMESTAMP - UTC)
# Format: YYYY-MM-DD HH:MM:SS
if ' ' in text:
# Assume UTC
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone() # Convert to local
except Exception:
pass
return None
def _extract_date(raw_timestamp: Any) -> str:
dt = _parse_to_local(raw_timestamp)
if dt:
return dt.strftime("%m-%d-%y")
# Fallback
if not raw_timestamp:
return ""
text = str(raw_timestamp)
# Extract YYYY-MM-DD part
date_part = ""
if "T" in text:
date_part = text.split("T", 1)[0]
elif " " in text:
date_part = text.split(" ", 1)[0]
else:
date_part = text
# Convert YYYY-MM-DD to MM-DD-YY
try:
parts = date_part.split("-")
if len(parts) == 3:
year, month, day = parts
return f"{month}-{day}-{year[2:]}"
except Exception:
pass
return date_part