Compare commits

..

10 Commits

Author SHA1 Message Date
nose
9eff65d1af jjlj 2025-11-27 10:59:01 -08:00
nose
e9b505e609 fixes 2025-11-26 00:29:10 -08:00
nose
935ce303d0 no logging 2025-11-26 00:02:33 -08:00
nose
d1f08216a2 fix search query parsing to allow multi-word query 2025-11-25 23:12:15 -08:00
nose
f6ce155985 Merge branch 'main' of https://code.glowers.club/goyimnose/Medios-Macina 2025-11-25 22:36:47 -08:00
nose
4187b778d2 kk 2025-11-25 22:34:41 -08:00
caaefb5910 Update README.md 2025-11-25 23:33:12 -05:00
f5d7e7dac5 Update README.md 2025-11-25 23:27:45 -05:00
9b3d4c280b Update README.md 2025-11-25 23:27:14 -05:00
8d36b87d6e Update README.md 2025-11-25 23:24:20 -05:00
34 changed files with 2478 additions and 1434 deletions

2
.gitignore vendored
View File

@@ -6,7 +6,6 @@ __pycache__/
config.json
# C extensions
*.so
# Distribution / packaging
.Python
build/
@@ -217,3 +216,4 @@ luac.out
*.hex
config.json

187
CLI.py
View File

@@ -30,12 +30,16 @@ try:
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.lexers import Lexer
from prompt_toolkit.styles import Style
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError: # pragma: no cover - optional dependency
PromptSession = None # type: ignore
Completer = None # type: ignore
Completion = None # type: ignore
Document = None # type: ignore
Lexer = None # type: ignore
Style = None # type: ignore
PROMPT_TOOLKIT_AVAILABLE = False
@@ -236,6 +240,7 @@ def _close_cli_worker_manager() -> None:
global _CLI_WORKER_MANAGER
if _CLI_WORKER_MANAGER:
try:
# print("[CLI] Closing worker manager...", file=sys.stderr)
_CLI_WORKER_MANAGER.close()
except Exception:
pass
@@ -269,7 +274,7 @@ def _ensure_worker_manager(config: Dict[str, Any]) -> Optional[WorkerManagerType
_CLI_WORKER_MANAGER.close()
except Exception:
pass
_CLI_WORKER_MANAGER = WorkerManager(resolved_root, auto_refresh_interval=0)
_CLI_WORKER_MANAGER = WorkerManager(resolved_root, auto_refresh_interval=0.5)
manager = _CLI_WORKER_MANAGER
config['_worker_manager'] = manager
if manager and not _CLI_ORPHAN_CLEANUP_DONE:
@@ -531,6 +536,46 @@ if (
async def get_completions_async(self, document: Document, complete_event): # type: ignore[override]
for completion in self.get_completions(document, complete_event):
yield completion
class MedeiaLexer(Lexer):
def lex_document(self, document):
def get_line(lineno):
line = document.lines[lineno]
tokens = []
import re
# Match: Whitespace, Pipe, Quoted string, or Word
pattern = re.compile(r'''
(\s+) | # 1. Whitespace
(\|) | # 2. Pipe
("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*') | # 3. Quoted string
([^\s\|]+) # 4. Word
''', re.VERBOSE)
is_cmdlet = True
for match in pattern.finditer(line):
ws, pipe, quote, word = match.groups()
if ws:
tokens.append(('', ws))
elif pipe:
tokens.append(('class:pipe', pipe))
is_cmdlet = True
elif quote:
tokens.append(('class:string', quote))
is_cmdlet = False
elif word:
if is_cmdlet:
tokens.append(('class:cmdlet', word))
is_cmdlet = False
elif word.startswith('-'):
tokens.append(('class:argument', word))
else:
tokens.append(('class:value', word))
return tokens
return get_line
else: # pragma: no cover - prompt toolkit unavailable
CmdletCompleter = None # type: ignore[assignment]
@@ -542,17 +587,72 @@ def _create_cmdlet_cli():
app = typer.Typer(help="Medeia-Macina CLI")
@app.command("pipeline")
def pipeline(
command: str = typer.Option(..., "--pipeline", "-p", help="Pipeline command string to execute"),
seeds_json: Optional[str] = typer.Option(None, "--seeds-json", "-s", help="JSON string of seed items")
):
"""Execute a pipeline command non-interactively."""
import shlex
import json
import pipeline as ctx
# Load config
config = _load_cli_config()
# Initialize debug logging if enabled
if config:
from helper.logger import set_debug
set_debug(config.get("debug", False))
# Handle seeds if provided
if seeds_json:
try:
seeds = json.loads(seeds_json)
# If seeds is a list, use it directly. If single item, wrap in list.
if not isinstance(seeds, list):
seeds = [seeds]
# Set seeds as the result of a "virtual" previous stage
# This allows the first command in the pipeline to receive them as input
ctx.set_last_result_items_only(seeds)
except Exception as e:
print(f"Error parsing seeds JSON: {e}")
return
try:
tokens = shlex.split(command)
except ValueError:
tokens = command.split()
if not tokens:
return
# Execute
_execute_pipeline(tokens)
@app.command("repl")
def repl():
"""Start interactive REPL for cmdlets with autocomplete."""
banner = """
Medeia-Macina
=======================================
Commands: help | exit | <cmdlet> --help
Example: search-file --help
Medeia-Macina
=====================
|123456789|ABCDEFGHI|
|246813579|JKLMNOPQR|
|369369369|STUVWXYZ0|
|483726159|ABCDEFGHI|
|516273849|JKLMNOPQR|
|639639639|STUVWXYZ0|
|753186429|ABCDEFGHI|
|876543219|JKLMNOPQR|
|999999999|STUVWXYZ0|
=====================
"""
print(banner)
# Configurable prompt
prompt_text = ">>>|"
# Pre-acquire Hydrus session key at startup (like hub-ui does)
try:
config = _load_cli_config()
@@ -577,27 +677,44 @@ Example: search-file --help
# Check MPV availability at startup
try:
from hydrus_health_check import check_mpv_availability
from hydrus_health_check import check_mpv_availability, initialize_matrix_health_check, initialize_hydrus_health_check
check_mpv_availability()
initialize_hydrus_health_check(config)
initialize_matrix_health_check(config)
except Exception as e:
debug(f"⚠ Could not check MPV availability: {e}")
debug(f"⚠ Could not check service availability: {e}")
except Exception:
pass # Silently ignore if config loading fails
if PROMPT_TOOLKIT_AVAILABLE and PromptSession is not None and CmdletCompleter is not None:
completer = CmdletCompleter()
session = PromptSession(completer=cast(Any, completer))
def get_input(prompt: str = ">>>|") -> str:
# Define style for syntax highlighting
style = Style.from_dict({
'cmdlet': '#ffffff', # white
'argument': '#3b8eea', # blue-ish
'value': "#9a3209", # red-ish
'string': "#6d0d93", # purple
'pipe': '#4caf50', # green
})
session = PromptSession(
completer=cast(Any, completer),
lexer=MedeiaLexer(),
style=style
)
def get_input(prompt: str = prompt_text) -> str:
return session.prompt(prompt)
else:
def get_input(prompt: str = ">>>|") -> str:
def get_input(prompt: str = prompt_text) -> str:
return input(prompt)
while True:
print("#-------------------------------------------------------------------------#")
try:
user_input = get_input(">>>|").strip()
user_input = get_input(prompt_text).strip()
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
@@ -645,6 +762,7 @@ Example: search-file --help
if last_table is None:
last_table = ctx.get_last_result_table()
if last_table:
print()
# Also update current stage table so @N expansion works correctly
@@ -683,6 +801,17 @@ Example: search-file --help
if pipeline_ctx_ref:
pipeline_ctx_ref.clear_current_command_text()
@app.callback(invoke_without_command=True)
def main_callback(ctx: typer.Context):
"""
Medeia-Macina CLI entry point.
If no command is provided, starts the interactive REPL.
"""
# Check if a subcommand is invoked
# Note: ctx.invoked_subcommand is None if no command was passed
if ctx.invoked_subcommand is None:
repl()
return app
@@ -779,10 +908,10 @@ def _execute_pipeline(tokens: list):
else:
# Try command-based expansion first if we have source command info
command_expanded = False
selected_row_args = []
if source_cmd:
# Try to find row args for the selected indices
selected_row_args = []
for idx in first_stage_selection_indices:
row_args = ctx.get_current_stage_table_row_selection_args(idx)
if row_args:
@@ -827,33 +956,10 @@ def _execute_pipeline(tokens: list):
source_args = ctx.get_last_result_table_source_args()
if source_cmd == 'search-file' and source_args and 'youtube' in source_args:
print(f"Auto-piping YouTube selection to .pipe")
# We can't modify stages here easily as we are outside the loop or before it?
# Actually, this block runs BEFORE the loop if stages[0] is a selection.
# But wait, the loop iterates over stages.
# If we are here, it means we handled the selection by filtering `piped_result`.
# The loop will then execute stages starting from 0?
# No, `_execute_pipeline` logic is complex.
# Let's look at where this block is.
# It is inside `_execute_pipeline`.
# It runs if `first_stage_selection_indices` is set (meaning stages[0] was a selection).
# And `command_expanded` is False (meaning we didn't replace stages[0] with a command).
# If we are here, `piped_result` holds the selected item(s).
# The loop below iterates `for stage_index, stage_tokens in enumerate(stages):`
# But we removed the first stage from `stages`? No.
# Wait, let's check how `first_stage_selection_indices` is used.
# It seems `stages` is modified earlier?
# "if stages and stages[0] and stages[0][0].startswith('@'): ... stages.pop(0)"
# Yes, lines 750-760 (approx) pop the first stage if it is a selection.
# So `stages` now contains the REST of the pipeline.
# If user typed just `@1`, `stages` is now empty `[]`.
# So if we want to pipe to `.pipe`, we should append `.pipe` to `stages`.
stages.append(['.pipe'])
# Only auto-pipe if no other stages follow (stages is empty because we popped the selection)
if not stages:
print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe'])
else:
print(f"No items matched selection in pipeline\n")
@@ -1313,7 +1419,8 @@ def _execute_cmdlet(cmd_name: str, args: list):
# Special case: if this was a youtube search, print a hint about auto-piping
if cmd_name == 'search-file' and filtered_args and 'youtube' in filtered_args:
print("\n[Hint] Type @N to play a video in MPV (e.g. @1)")
# print("\n[Hint] Type @N to play a video in MPV (e.g. @1)")
pass
else:
# Fallback to raw output if ResultTable not available
for emitted in pipeline_ctx.emits:

121
LUA/main.lua Normal file
View File

@@ -0,0 +1,121 @@
local mp = require 'mp'
local utils = require 'mp.utils'
local msg = require 'mp.msg'
local M = {}
-- Configuration
local opts = {
python_path = "python",
cli_path = nil -- Will be auto-detected if nil
}
-- Detect CLI path
local script_dir = mp.get_script_directory()
if not opts.cli_path then
-- Assuming the structure is repo/LUA/script.lua and repo/CLI.py
-- We need to go up one level
local parent_dir = script_dir:match("(.*)[/\\]")
if parent_dir then
opts.cli_path = parent_dir .. "/CLI.py"
else
opts.cli_path = "CLI.py" -- Fallback
end
end
-- Helper to run pipeline
function M.run_pipeline(pipeline_cmd, seeds)
local args = {opts.python_path, opts.cli_path, "pipeline", pipeline_cmd}
if seeds then
local seeds_json = utils.format_json(seeds)
table.insert(args, "--seeds")
table.insert(args, seeds_json)
end
msg.info("Running pipeline: " .. pipeline_cmd)
local res = utils.subprocess({
args = args,
cancellable = false,
})
if res.status ~= 0 then
msg.error("Pipeline error: " .. (res.stderr or "unknown"))
mp.osd_message("Error: " .. (res.stderr or "unknown"), 5)
return nil
end
return res.stdout
end
-- Helper to run pipeline and parse JSON output
function M.run_pipeline_json(pipeline_cmd, seeds)
-- Append | output-json if not present
if not pipeline_cmd:match("output%-json$") then
pipeline_cmd = pipeline_cmd .. " | output-json"
end
local output = M.run_pipeline(pipeline_cmd, seeds)
if output then
local ok, data = pcall(utils.parse_json, output)
if ok then
return data
else
msg.error("Failed to parse JSON: " .. output)
return nil
end
end
return nil
end
-- Command: Get info for current file
function M.get_file_info()
local path = mp.get_property("path")
if not path then return end
-- We can pass the path as a seed item
local seed = {{path = path}}
-- Run pipeline: get-metadata
local data = M.run_pipeline_json("get-metadata", seed)
if data then
-- Display metadata
msg.info("Metadata: " .. utils.format_json(data))
mp.osd_message("Metadata loaded (check console)", 3)
end
end
-- Command: Delete current file
function M.delete_current_file()
local path = mp.get_property("path")
if not path then return end
local seed = {{path = path}}
M.run_pipeline("delete-file", seed)
mp.osd_message("File deleted", 3)
mp.command("playlist-next")
end
-- Menu integration with UOSC
function M.show_menu()
local menu_data = {
title = "Medios Macina",
items = {
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
}
}
local json = utils.format_json(menu_data)
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
end
-- Keybindings
mp.add_key_binding("m", "medios-menu", M.show_menu)
mp.add_key_binding("mbtn_right", "medios-menu-right-click", M.show_menu)
mp.add_key_binding("ctrl+i", "medios-info", M.get_file_info)
mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)
return M

View File

@@ -1,64 +0,0 @@
# Medeia-Macina
A powerful CLI media management and search platform integrating local files, Hydrus, torrents, books, and P2P networks.
## Key Features
* **Unified Search**: Search across Local, Hydrus, LibGen, Soulseek, and Debrid.
* **Pipeline Architecture**: Chain commands like PowerShell (e.g., `search | filter | download`).
* **Smart Selection**: Use `@N` syntax to interact with results.
* **Metadata Management**: Tagging, notes, and relationships.
## Installation
1. Install Python 3.9+ and [Deno](https://deno.com/) (for YouTube support).
2. Install dependencies: `pip install -r requirements.txt`
3. Run the CLI: `python CLI.py`
## Command Examples
### Search & Download
```powershell
# Search and download the first result
search-file "daughter" | @1 | download-data
# Search specific provider and download
search-file -provider libgen "dune" | @1 | download-data
# Download YouTube video (auto-probes formats)
download-data "https://youtube.com/watch?v=..."
# Select format #2 from the list
@2 | download-data
```
### File Management
```powershell
# Add file to Hydrus
add-file -path "C:\Videos\movie.mp4" -storage hydrus
# Upload to 0x0.st and associate URL with Hydrus file
search-file "my_video" | @1 | add-file -provider 0x0
# Add tags to a file
search-file "video" | @1 | add-tag "creator:someone, character:hero"
# Use tag lists (from helper/adjective.json)
@1 | add-tag "{gnostic}"
```
### Metadata & Notes
```powershell
# Add a note
search-file "doc" | @1 | add-note "comment" "This is important"
# Get tags
search-file "image" | @1 | get-tag
```
### Pipeline Syntax
* `|` : Pipe results from one command to another.
* `@N` : Select the Nth item from the previous result (e.g., `@1`).
* `@N-M` : Select a range (e.g., `@1-5`).
* `@{1,3,5}` : Select specific items.
* `@*` : Select all items.
## Configuration
Edit `config.json` to set API keys (AllDebrid, OpenAI), storage paths, and Hydrus credentials.

View File

@@ -1,8 +1,6 @@
# Medios-Macina
media management
first: edit the config.json and save, if you dont have certain things use null with no quotes,
second: install the python application
python cli.py
@@ -11,9 +9,3 @@ python cli.py
2. @1
1. download-data "https://altrusiangrace.bandcamp.com/album/ancient-egyptian-legends-full-audiobook" | merge-file | add-file -storage local
1. screen-shot "https://code.glowers.club/goyimnose/Medios-Macina" | add-tag "mmmm" | add-file -storage local
2. search-file "mmmm"
3. @1 | get-file

View File

@@ -68,37 +68,6 @@ def group_tags_by_namespace(tags: Sequence[str]) -> Dict[str, List[str]]:
return grouped
def build_metadata_snapshot(file_path: Path) -> Dict[str, Any]:
"""Load any available sidecar metadata for the selected file."""
snapshot: Dict[str, Any] = {
"file": str(file_path),
"tags": group_tags_by_namespace(load_tags(file_path)),
}
try:
sidecar = metadata._derive_sidecar_path(file_path)
if sidecar.is_file():
title, tags, notes = metadata._read_sidecar_metadata(sidecar)
snapshot["sidecar"] = {
"title": title,
"tags": group_tags_by_namespace(tags),
"notes": notes,
}
except Exception:
snapshot["sidecar"] = None
return snapshot
def summarize_result(result: Dict[str, Any]) -> str:
"""Build a one-line summary for a pipeline result row."""
title = result.get("title") or result.get("identifier") or result.get("file_path")
source = result.get("source") or result.get("cmdlet") or "result"
return f"{source}: {title}" if title else source
def normalize_tags(tags: Iterable[str]) -> List[str]:
"""Expose metadata.normalize_tags for callers that imported the old helper."""

View File

@@ -69,33 +69,34 @@ class ExportModal(ModalScreen):
"""
ext_lower = ext.lower() if ext else ''
# Audio formats
audio_exts = {'.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.wma', '.opus', '.mka'}
audio_formats = [("MKA", "mka"), ("MP3", "mp3"), ("M4A", "m4a"), ("FLAC", "flac"), ("WAV", "wav"), ("AAC", "aac"), ("OGG", "ogg"), ("Opus", "opus")]
from helper.utils_constant import mime_maps
# Video formats (can have audio too)
video_exts = {'.mp4', '.mkv', '.webm', '.avi', '.mov', '.flv', '.wmv', '.m4v', '.ts', '.mpg', '.mpeg'}
video_formats = [("MP4", "mp4"), ("MKV", "mkv"), ("WebM", "webm"), ("AVI", "avi"), ("MOV", "mov")]
found_type = "unknown"
# Image formats
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.ico'}
image_formats = [("JPG", "jpg"), ("PNG", "png"), ("WebP", "webp"), ("GIF", "gif"), ("BMP", "bmp")]
# Find type based on extension
for category, formats in mime_maps.items():
for fmt_key, fmt_info in formats.items():
if fmt_info.get("ext") == ext_lower:
found_type = category
break
if found_type != "unknown":
break
# Document formats - no conversion for now
document_exts = {'.pdf', '.epub', '.txt', '.docx', '.doc', '.rtf', '.md', '.html', '.mobi', '.cbz', '.cbr'}
document_formats = []
# Build format options for the found type
format_options = []
if ext_lower in audio_exts:
return ('audio', audio_formats)
elif ext_lower in video_exts:
return ('video', video_formats)
elif ext_lower in image_exts:
return ('image', image_formats)
elif ext_lower in document_exts:
return ('document', document_formats)
else:
# Default to audio if unknown
return ('unknown', audio_formats)
# If unknown, fallback to audio (matching legacy behavior)
target_type = found_type if found_type in mime_maps else "audio"
if target_type in mime_maps:
# Sort formats alphabetically
sorted_formats = sorted(mime_maps[target_type].items())
for fmt_key, fmt_info in sorted_formats:
label = fmt_key.upper()
value = fmt_key
format_options.append((label, value))
return (target_type, format_options)
def _get_library_options(self) -> list:
"""Get available library options from config.json."""

View File

@@ -15,6 +15,8 @@ import asyncio
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import load_config
from result_table import ResultTable
from helper.search_provider import get_provider
logger = logging.getLogger(__name__)
@@ -49,7 +51,8 @@ class SearchModal(ModalScreen):
self.results_table: Optional[DataTable] = None
self.tags_textarea: Optional[TextArea] = None
self.library_source_select: Optional[Select] = None
self.current_results: List[dict] = []
self.current_results: List[Any] = [] # List of SearchResult objects
self.current_result_table: Optional[ResultTable] = None
self.is_searching = False
self.current_worker = None # Track worker for search operations
@@ -125,124 +128,6 @@ class SearchModal(ModalScreen):
# Focus on search input
self.search_input.focus()
async def _search_openlibrary(self, query: str) -> List[dict]:
"""Search OpenLibrary for books."""
try:
from helper.search_provider import get_provider
logger.info(f"[search-modal] Searching OpenLibrary for: {query}")
# Get the OpenLibrary provider (now has smart search built-in)
provider = get_provider("openlibrary")
if not provider:
logger.error("[search-modal] OpenLibrary provider not available")
return []
# Search using the provider (smart search is now default)
search_results = provider.search(query, limit=20)
formatted_results = []
for result in search_results:
# Extract metadata from SearchResult.full_metadata
metadata = result.full_metadata or {}
formatted_results.append({
"title": result.title,
"author": ", ".join(metadata.get("authors", [])) if metadata.get("authors") else "Unknown",
"year": metadata.get("year", ""),
"publisher": metadata.get("publisher", ""),
"isbn": metadata.get("isbn", ""),
"oclc": metadata.get("oclc", ""),
"lccn": metadata.get("lccn", ""),
"openlibrary_id": metadata.get("olid", ""),
"pages": metadata.get("pages", ""),
"language": metadata.get("language", ""),
"source": "openlibrary",
"columns": result.columns,
"raw_data": metadata
})
logger.info(f"[search-modal] Found {len(formatted_results)} OpenLibrary results")
return formatted_results
except Exception as e:
logger.error(f"[search-modal] OpenLibrary search error: {e}", exc_info=True)
import traceback
traceback.print_exc()
return []
async def _search_soulseek(self, query: str) -> List[dict]:
"""Search Soulseek for music with automatic worker tracking."""
try:
from helper.search_provider import get_provider
# Create worker for tracking
worker = None
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
worker = self.app_instance.create_worker(
'soulseek',
title=f"Soulseek Search: {query[:40]}",
description=f"Searching P2P network for music"
)
self.current_worker = worker
if worker:
worker.log_step("Connecting to Soulseek peer network...")
logger.info(f"[search-modal] Searching Soulseek for: {query}")
provider = get_provider("soulseek")
search_results = provider.search(query, limit=20)
if worker:
worker.log_step(f"Search returned {len(search_results)} results")
logger.info(f"[search-modal] Found {len(search_results)} Soulseek results")
# Format results for display
formatted_results = []
for idx, result in enumerate(search_results):
metadata = result.full_metadata or {}
artist = metadata.get('artist', '')
album = metadata.get('album', '')
title = result.title
track_num = metadata.get('track_num', '')
size_bytes = result.size_bytes or 0
# Format size as human-readable
if size_bytes > 1024 * 1024:
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
elif size_bytes > 1024:
size_str = f"{size_bytes / 1024:.1f} KB"
else:
size_str = f"{size_bytes} B"
# Build columns for display
columns = [
("#", str(idx + 1)),
("Title", title[:50] if title else "Unknown"),
("Artist", artist[:30] if artist else "(no artist)"),
("Album", album[:30] if album else ""),
]
formatted_results.append({
"title": title if title else "Unknown",
"artist": artist if artist else "(no artist)",
"album": album,
"track": track_num,
"filesize": size_str,
"bitrate": "", # Not available in Soulseek results
"source": "soulseek",
"columns": columns,
"raw_data": result.to_dict()
})
return formatted_results
except Exception as e:
logger.error(f"[search-modal] Soulseek search error: {e}")
import traceback
traceback.print_exc()
return []
async def _perform_search(self) -> None:
"""Perform the actual search based on selected source."""
if not self.search_input or not self.source_select or not self.results_table:
@@ -257,87 +142,69 @@ class SearchModal(ModalScreen):
source = self.source_select.value
# Clear existing results
self.results_table.clear()
self.results_table.clear(columns=True)
self.current_results = []
self.current_result_table = None
self.is_searching = True
# Create worker for tracking
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
self.current_worker = self.app_instance.create_worker(
source,
title=f"{source.capitalize()} Search: {query[:40]}",
description=f"Searching {source} for: {query}"
)
self.current_worker.log_step(f"Connecting to {source}...")
try:
if source == "openlibrary":
results = await self._search_openlibrary(query)
elif source == "soulseek":
results = await self._search_soulseek(query)
else:
logger.warning(f"[search-modal] Unknown source: {source}")
provider = get_provider(source)
if not provider:
logger.error(f"[search-modal] Provider not available: {source}")
if self.current_worker:
self.current_worker.finish("error", "Unknown search source")
self.current_worker.finish("error", f"Provider not available: {source}")
return
logger.info(f"[search-modal] Searching {source} for: {query}")
results = provider.search(query, limit=20)
self.current_results = results
# Populate table with results
if results:
# Check if first result has columns field
first_result = results[0]
if "columns" in first_result and first_result["columns"]:
# Use dynamic columns from result
# Clear existing columns and rebuild based on result columns
self.results_table.clear()
if self.current_worker:
self.current_worker.log_step(f"Found {len(results)} results")
# Extract column headers from first result's columns field
column_headers = [col[0] for col in first_result["columns"]]
# Remove existing columns (we'll readd them with the right headers)
# Note: This is a workaround since Textual's DataTable doesn't support dynamic column management well
# For now, we just use the dynamic column headers from the result
logger.info(f"[search-modal] Using dynamic columns: {column_headers}")
# Populate rows using the column order from results
for result in results:
if "columns" in result and result["columns"]:
# Extract values in column order
row_data = [col[1] for col in result["columns"]]
self.results_table.add_row(*row_data)
else:
# Fallback for results without columns
logger.warning(f"[search-modal] Result missing columns field: {result.get('title', 'Unknown')}")
# Create ResultTable
table = ResultTable(f"Search Results: {query}")
for res in results:
row = table.add_row()
# Add columns from result.columns
if res.columns:
for name, value in res.columns:
row.add_column(name, value)
else:
# Fallback to original hardcoded behavior if columns not available
logger.info("[search-modal] No dynamic columns found, using default formatting")
# Fallback if no columns defined
row.add_column("Title", res.title)
row.add_column("Target", res.target)
for result in results:
if source == "openlibrary":
# Format OpenLibrary results (original hardcoded)
year = str(result.get("year", ""))[:4] if result.get("year") else ""
details = f"ISBN: {result.get('isbn', '')}" if result.get('isbn') else ""
if result.get('openlibrary_id'):
details += f" | OL: {result.get('openlibrary_id')}"
self.current_result_table = table
row_data = [
result["title"][:60],
result["author"][:35],
year,
details[:40]
]
else: # soulseek
row_data = [
result["title"][:50],
result["artist"][:30],
result["album"][:30],
result['filesize']
]
self.results_table.add_row(*row_data)
# Populate UI
if table.rows:
# Add headers
headers = [col.name for col in table.rows[0].columns]
self.results_table.add_columns(*headers)
# Add rows
for row_vals in table.to_datatable_rows():
self.results_table.add_row(*row_vals)
else:
# Add a "no results" message
self.results_table.add_row("No results found", "", "", "")
self.results_table.add_columns("Message")
self.results_table.add_row("No results found")
# Finish worker if tracking
# Finish worker
if self.current_worker:
self.current_worker.finish("completed", f"Found {len(results)} results")
except Exception as e:
logger.error(f"[search-modal] Search error: {e}")
logger.error(f"[search-modal] Search error: {e}", exc_info=True)
if self.current_worker:
self.current_worker.finish("error", f"Search failed: {str(e)}")
@@ -382,35 +249,58 @@ class SearchModal(ModalScreen):
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
# Convert to dict if needed for submission
if hasattr(result, 'to_dict'):
result_dict = result.to_dict()
else:
result_dict = result
# Get tags from textarea
tags_text = self.tags_textarea.text if self.tags_textarea else ""
# Get library source (if OpenLibrary)
library_source = self.library_source_select.value if self.library_source_select else "local"
# Add tags and source to result
result["tags_text"] = tags_text
result["library_source"] = library_source
result_dict["tags_text"] = tags_text
result_dict["library_source"] = library_source
# Post message and dismiss
self.post_message(self.SearchSelected(result))
self.dismiss(result)
self.post_message(self.SearchSelected(result_dict))
self.dismiss(result_dict)
else:
logger.warning("[search-modal] No result selected for submission")
elif button_id == "cancel-button":
self.dismiss(None)
def _populate_tags_from_result(self, result: dict) -> None:
def _populate_tags_from_result(self, result: Any) -> None:
"""Populate the tags textarea from a selected result."""
if not self.tags_textarea:
return
# Handle both SearchResult objects and dicts
if hasattr(result, 'full_metadata'):
metadata = result.full_metadata or {}
source = result.origin
title = result.title
else:
# Handle dict (legacy or from to_dict)
if 'full_metadata' in result:
metadata = result['full_metadata'] or {}
elif 'raw_data' in result:
metadata = result['raw_data'] or {}
else:
metadata = result
source = result.get('origin', result.get('source', ''))
title = result.get('title', '')
# Format tags based on result source
if result.get("source") == "openlibrary":
if source == "openlibrary":
# For OpenLibrary: title, author, year
title = result.get("title", "")
author = result.get("author", "")
year = result.get("year", "")
author = ", ".join(metadata.get("authors", [])) if isinstance(metadata.get("authors"), list) else metadata.get("authors", "")
year = str(metadata.get("year", ""))
tags = []
if title:
tags.append(title)
@@ -419,38 +309,51 @@ class SearchModal(ModalScreen):
if year:
tags.append(year)
tags_text = "\n".join(tags)
else: # soulseek
elif source == "soulseek":
# For Soulseek: artist, album, title, track
tags = []
if result.get("artist"):
tags.append(result["artist"])
if result.get("album"):
tags.append(result["album"])
if result.get("track"):
tags.append(f"Track {result['track']}")
if result.get("title"):
tags.append(result["title"])
if metadata.get("artist"):
tags.append(metadata["artist"])
if metadata.get("album"):
tags.append(metadata["album"])
if metadata.get("track_num"):
tags.append(f"Track {metadata['track_num']}")
if title:
tags.append(title)
tags_text = "\n".join(tags)
else:
# Generic fallback
tags = [title]
tags_text = "\n".join(tags)
self.tags_textarea.text = tags_text
logger.info(f"[search-modal] Populated tags textarea from result")
async def _download_book(self, result: dict) -> None:
async def _download_book(self, result: Any) -> None:
"""Download a book from OpenLibrary using unified downloader."""
try:
from helper.unified_book_downloader import UnifiedBookDownloader
from config import load_config
logger.info(f"[search-modal] Starting download for: {result.get('title')}")
# Convert SearchResult to dict if needed
if hasattr(result, 'to_dict'):
result_dict = result.to_dict()
# Ensure raw_data is populated for downloader
if 'raw_data' not in result_dict and result.full_metadata:
result_dict['raw_data'] = result.full_metadata
else:
result_dict = result
logger.info(f"[search-modal] Starting download for: {result_dict.get('title')}")
config = load_config()
downloader = UnifiedBookDownloader(config=config)
# Get download options for this book
options = downloader.get_download_options(result)
options = downloader.get_download_options(result_dict)
if not options['methods']:
logger.warning(f"[search-modal] No download methods available for: {result.get('title')}")
logger.warning(f"[search-modal] No download methods available for: {result_dict.get('title')}")
# Could show a modal dialog here
return

View File

@@ -40,6 +40,7 @@ class PipelineStageResult:
name: str
args: Sequence[str]
emitted: List[Any] = field(default_factory=list)
result_table: Optional[Any] = None # ResultTable object if available
status: str = "pending"
error: Optional[str] = None
@@ -52,6 +53,7 @@ class PipelineRunResult:
success: bool
stages: List[PipelineStageResult] = field(default_factory=list)
emitted: List[Any] = field(default_factory=list)
result_table: Optional[Any] = None # Final ResultTable object if available
stdout: str = ""
stderr: str = ""
error: Optional[str] = None
@@ -146,6 +148,7 @@ class PipelineExecutor:
if index == len(stages) - 1:
result.emitted = stage.emitted
result.result_table = stage.result_table
else:
piped_result = stage.emitted
@@ -212,6 +215,10 @@ class PipelineExecutor:
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
stage.emitted = emitted
# Capture the ResultTable if the cmdlet set one
# Check display table first (overlay), then last result table
stage.result_table = ctx.get_display_table() or ctx.get_last_result_table()
if return_code != 0:
stage.status = "failed"
stage.error = f"Exit code {return_code}"
@@ -224,7 +231,12 @@ class PipelineExecutor:
label = f"[Stage {index + 1}/{total}] {cmd_name} {stage.status}"
self._worker_manager.log_step(worker_id, label)
ctx.set_last_result_table(None, emitted)
# Don't clear the table if we just captured it, but ensure items are set for next stage
# If we have a table, we should probably keep it in ctx for history if needed
# But for pipeline execution, we mainly care about passing items to next stage
# ctx.set_last_result_table(None, emitted) <-- This was clearing it
# Ensure items are available for next stage
ctx.set_last_items(emitted)
return stage

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from typing import Any, List, Optional, Sequence
from textual import work
from textual.app import App, ComposeResult
@@ -32,10 +32,9 @@ for path in (BASE_DIR, ROOT_DIR):
from menu_actions import ( # type: ignore # noqa: E402
PIPELINE_PRESETS,
PipelinePreset,
build_metadata_snapshot,
summarize_result,
)
from pipeline_runner import PipelineExecutor, PipelineRunResult # type: ignore # noqa: E402
from result_table import ResultTable # type: ignore # noqa: E402
class PresetListItem(ListItem):
@@ -73,6 +72,7 @@ class PipelineHubApp(App):
self.worker_table: Optional[DataTable] = None
self.preset_list: Optional[ListView] = None
self.status_panel: Optional[Static] = None
self.current_result_table: Optional[ResultTable] = None
self._pipeline_running = False
# ------------------------------------------------------------------
@@ -81,7 +81,7 @@ class PipelineHubApp(App):
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
yield Header(show_clock=True)
with Container(id="app-shell"):
with Horizontal(id="command-row"):
with Horizontal(id="command-pane"):
self.command_input = Input(
placeholder='download-data "<url>" | merge-file | add-tag | add-file -storage local',
id="pipeline-input",
@@ -174,7 +174,7 @@ class PipelineHubApp(App):
return
index = event.cursor_row
if 0 <= index < len(self.result_items):
self._display_metadata(self.result_items[index])
self._display_metadata(index)
# ------------------------------------------------------------------
# Pipeline execution helpers
@@ -216,6 +216,7 @@ class PipelineHubApp(App):
else:
self.result_items = []
self.current_result_table = run_result.result_table
self._populate_results_table()
self.refresh_workers()
@@ -228,40 +229,45 @@ class PipelineHubApp(App):
def _populate_results_table(self) -> None:
if not self.results_table:
return
self.results_table.clear()
if not self.result_items:
self.results_table.add_row("", "No results", "", "")
return
for idx, item in enumerate(self.result_items, start=1):
if isinstance(item, dict):
title = summarize_result(item)
source = item.get("source") or item.get("cmdlet_name") or item.get("cmdlet") or ""
file_path = item.get("file_path") or item.get("path") or ""
else:
title = str(item)
source = ""
file_path = ""
self.results_table.add_row(str(idx), title, source, file_path, key=str(idx - 1))
self.results_table.clear(columns=True)
def _display_metadata(self, item: Any) -> None:
if self.current_result_table and self.current_result_table.rows:
# Use ResultTable headers from the first row
first_row = self.current_result_table.rows[0]
headers = ["#"] + [col.name for col in first_row.columns]
self.results_table.add_columns(*headers)
rows = self.current_result_table.to_datatable_rows()
for idx, row_values in enumerate(rows, 1):
self.results_table.add_row(str(idx), *row_values, key=str(idx - 1))
else:
# Fallback or empty state
self.results_table.add_columns("Row", "Title", "Source", "File")
if not self.result_items:
self.results_table.add_row("", "No results", "", "")
return
# Fallback for items without a table
for idx, item in enumerate(self.result_items, start=1):
self.results_table.add_row(str(idx), str(item), "", "", key=str(idx - 1))
def _display_metadata(self, index: int) -> None:
if not self.metadata_tree:
return
root = self.metadata_tree.root
root.label = "Metadata"
root.remove_children()
payload: Dict[str, Any]
if isinstance(item, dict):
file_path = item.get("file_path") or item.get("path")
if file_path:
payload = build_metadata_snapshot(Path(file_path))
if self.current_result_table and 0 <= index < len(self.current_result_table.rows):
row = self.current_result_table.rows[index]
for col in row.columns:
root.add(f"[b]{col.name}[/b]: {col.value}")
elif 0 <= index < len(self.result_items):
item = self.result_items[index]
if isinstance(item, dict):
self._populate_tree_node(root, item)
else:
payload = item
else:
payload = {"value": str(item)}
self._populate_tree_node(root, payload)
root.expand_all()
root.add(str(item))
def _populate_tree_node(self, node, data: Any) -> None:
if isinstance(data, dict):
@@ -278,14 +284,14 @@ class PipelineHubApp(App):
def _clear_log(self) -> None:
self.log_lines = []
if self.log_output:
self.log_output.value = ""
self.log_output.text = ""
def _append_log_line(self, line: str) -> None:
self.log_lines.append(line)
if len(self.log_lines) > 500:
self.log_lines = self.log_lines[-500:]
if self.log_output:
self.log_output.value = "\n".join(self.log_lines)
self.log_output.text = "\n".join(self.log_lines)
def _append_block(self, text: str) -> None:
for line in text.strip().splitlines():

View File

@@ -6,7 +6,7 @@
layout: vertical;
}
#command-row {
#command-pane {
width: 100%;
height: auto;
background: $boost;
@@ -18,7 +18,6 @@
width: 1fr;
min-height: 3;
padding: 0 1;
margin-right: 1;
background: $surface;
color: $text;
border: round $primary;
@@ -30,7 +29,9 @@
}
#status-panel {
min-width: 20;
width: auto;
max-width: 25;
height: 3;
text-style: bold;
content-align: center middle;
padding: 0 1;
@@ -52,7 +53,7 @@
}
#left-pane {
max-width: 48;
max-width: 60;
}
.section-title {
@@ -67,6 +68,11 @@
margin-bottom: 1;
}
#preset-list {
height: 25;
border: solid $secondary;
}
#log-output {
height: 16;
}
@@ -98,3 +104,9 @@
background: $error 20%;
color: $error;
}
#run-button {
width: auto;
min-width: 10;
margin: 0 1;
}

View File

@@ -119,7 +119,9 @@ for filename in os.listdir(cmdlet_dir):
for alias in cmdlet_obj.aliases:
normalized_alias = alias.replace('_', '-').lower()
REGISTRY[normalized_alias] = run_fn
except Exception:
except Exception as e:
import sys
print(f"Error importing cmdlet '{mod_name}': {e}", file=sys.stderr)
continue
# Import root-level modules that also register cmdlets

View File

@@ -371,7 +371,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
# Extract tags/known URLs from pipeline objects if available
pipe_object_tags = extract_tags_from_result(result)
if pipe_object_tags:
log(f"Extracted {len(pipe_object_tags)} tag(s) from pipeline result: {', '.join(pipe_object_tags[:5])}", file=sys.stderr)
debug(f"Extracted {len(pipe_object_tags)} tag(s) from pipeline result: {', '.join(pipe_object_tags[:5])}", file=sys.stderr)
pipe_known_urls = extract_known_urls_from_result(result)
# Resolve media path: get from piped result
@@ -574,11 +574,11 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
try:
file_provider = get_file_provider(provider_name, config)
if file_provider is None:
log(f"File provider '{provider_name}' not available", file=sys.stderr)
log(f"File provider '{provider_name}' not available", file=sys.stderr)
return 1
hoster_url = file_provider.upload(media_path)
log(f"File uploaded to {provider_name}: {hoster_url}", file=sys.stderr)
log(f"File uploaded to {provider_name}: {hoster_url}", file=sys.stderr)
# Associate the URL with the file in Hydrus if possible
current_hash = locals().get('file_hash')
@@ -590,12 +590,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
client = hydrus_wrapper.get_client(config)
if client:
client.associate_url(current_hash, hoster_url)
log(f"Associated URL with file hash {current_hash}", file=sys.stderr)
debug(f"Associated URL with file hash {current_hash}", file=sys.stderr)
except Exception as exc:
log(f"⚠️ Could not associate URL with Hydrus file: {exc}", file=sys.stderr)
log(f"Could not associate URL with Hydrus file: {exc}", file=sys.stderr)
except Exception as exc:
log(f"{provider_name} upload failed: {exc}", file=sys.stderr)
log(f"{provider_name} upload failed: {exc}", file=sys.stderr)
return 1
if delete_after_upload:
@@ -632,7 +632,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
log("❌ No local storage path configured. Set 'storage.local.path' in config.json", file=sys.stderr)
return 1
log(f"Moving into configured local library: {resolved_dir}", file=sys.stderr)
debug(f"Moving into configured local library: {resolved_dir}", file=sys.stderr)
exit_code, dest_path = _handle_local_transfer(media_path, Path(resolved_dir), result, config)
# After successful local transfer, emit result for pipeline continuation
@@ -713,7 +713,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception as exc:
log(f"❌ Failed to compute file hash: {exc}", file=sys.stderr)
return 1
log(f"File hash: {file_hash}", file=sys.stderr)
debug(f"File hash: {file_hash}", file=sys.stderr)
# Read sidecar tags and known URLs first (for tagging)
@@ -789,9 +789,9 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
config=config,
tags=tags,
)
log(f"✅ File uploaded to Hydrus: {file_hash}", file=sys.stderr)
log(f"Hydrus: {file_hash}", file=sys.stderr)
except Exception as exc:
log(f"❌ Hydrus upload failed: {exc}", file=sys.stderr)
log(f"Failed: {exc}", file=sys.stderr)
return 1
# Associate known URLs in Hydrus metadata

View File

@@ -13,7 +13,7 @@ import pipeline as ctx
from ._shared import normalize_result_input, filter_results_by_temp
from helper import hydrus as hydrus_wrapper
from helper.local_library import read_sidecar, write_sidecar, find_sidecar, has_sidecar, LocalLibraryDB
from metadata import rename_by_metadata
from metadata import rename
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args
from config import get_local_storage_path

View File

@@ -4,7 +4,7 @@ from typing import Any, Dict, Sequence
import json
import sys
from helper.logger import log
from helper.logger import debug, log
import sqlite3
from pathlib import Path
@@ -84,64 +84,28 @@ def _delete_database_entry(db_path: Path, file_path: str) -> bool:
return False
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
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
# Handle @N selection which creates a list - extract the first item
if isinstance(result, list) and len(result) > 0:
result = result[0]
# Parse overrides and options
override_hash: str | None = None
conserve: str | None = None
lib_root: str | None = None
reason_tokens: list[str] = []
i = 0
while i < len(args):
token = args[i]
low = str(token).lower()
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
override_hash = str(args[i + 1]).strip()
i += 2
continue
if low in {"-conserve", "--conserve"} and i + 1 < len(args):
value = str(args[i + 1]).strip().lower()
if value in {"local", "hydrus"}:
conserve = value
i += 2
continue
if low in {"-lib-root", "--lib-root", "lib-root"} and i + 1 < len(args):
lib_root = str(args[i + 1]).strip()
i += 2
continue
reason_tokens.append(token)
i += 1
# Handle result as either dict or object
if isinstance(result, dict):
hash_hex_raw = result.get("hash_hex") or result.get("hash")
target = result.get("target")
origin = result.get("origin")
def _process_single_item(item: Any, override_hash: str | None, conserve: str | None,
lib_root: str | None, reason: str, config: Dict[str, Any]) -> bool:
"""Process deletion for a single item."""
# Handle item as either dict or object
if isinstance(item, dict):
hash_hex_raw = item.get("hash_hex") or item.get("hash")
target = item.get("target")
origin = item.get("origin")
else:
hash_hex_raw = getattr(result, "hash_hex", None) or getattr(result, "hash", None)
target = getattr(result, "target", None)
origin = getattr(result, "origin", None)
hash_hex_raw = getattr(item, "hash_hex", None) or getattr(item, "hash", None)
target = getattr(item, "target", None)
origin = getattr(item, "origin", None)
# For Hydrus files, the target IS the hash
if origin and origin.lower() == "hydrus" and not hash_hex_raw:
hash_hex_raw = target
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_hex_raw)
reason = " ".join(token for token in reason_tokens if str(token).strip()).strip()
local_deleted = False
local_target = isinstance(target, str) and target.strip() and not str(target).lower().startswith(("http://", "https://"))
if conserve != "local" and local_target:
path = Path(str(target))
file_path_str = str(target) # Keep the original string for DB matching
@@ -168,8 +132,6 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if lib_root:
lib_root_path = Path(lib_root)
db_path = lib_root_path / ".downlow_library.db"
log(f"Attempting DB cleanup: lib_root={lib_root}, db_path={db_path}", file=sys.stderr)
log(f"Deleting DB entry for: {file_path_str}", file=sys.stderr)
if _delete_database_entry(db_path, file_path_str):
if ctx._PIPE_ACTIVE:
ctx.emit(f"Removed database entry: {path.name}")
@@ -178,7 +140,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
else:
log(f"Database entry not found or cleanup failed for {file_path_str}", file=sys.stderr)
else:
log(f"No lib_root provided, skipping database cleanup", file=sys.stderr)
debug(f"No lib_root provided, skipping database cleanup", file=sys.stderr)
hydrus_deleted = False
if conserve != "hydrus" and hash_hex:
@@ -187,12 +149,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception as exc:
if not local_deleted:
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
return 1
return False
else:
if client is None:
if not local_deleted:
log("Hydrus client unavailable", file=sys.stderr)
return 1
return False
else:
payload: Dict[str, Any] = {"hashes": [hash_hex]}
if reason:
@@ -201,11 +163,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
client._post("/add_files/delete_files", data=payload) # type: ignore[attr-defined]
hydrus_deleted = True
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
log(f"Deleted from Hydrus: {preview}", file=sys.stderr)
debug(f"Deleted from Hydrus: {preview}", file=sys.stderr)
except Exception as exc:
log(f"Hydrus delete failed: {exc}", file=sys.stderr)
if not local_deleted:
return 1
return False
if hydrus_deleted and hash_hex:
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
@@ -216,10 +178,64 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
ctx.emit(f"Deleted {preview}.")
if hydrus_deleted or local_deleted:
return 0
return True
log("Selected result has neither Hydrus hash nor local file target")
return 1
return False
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Help
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
override_hash: str | None = None
conserve: str | None = None
lib_root: str | None = None
reason_tokens: list[str] = []
i = 0
while i < len(args):
token = args[i]
low = str(token).lower()
if low in {"-hash", "--hash", "hash"} and i + 1 < len(args):
override_hash = str(args[i + 1]).strip()
i += 2
continue
if low in {"-conserve", "--conserve"} and i + 1 < len(args):
value = str(args[i + 1]).strip().lower()
if value in {"local", "hydrus"}:
conserve = value
i += 2
continue
if low in {"-lib-root", "--lib-root", "lib-root"} and i + 1 < len(args):
lib_root = str(args[i + 1]).strip()
i += 2
continue
reason_tokens.append(token)
i += 1
reason = " ".join(token for token in reason_tokens if str(token).strip()).strip()
items = []
if isinstance(result, list):
items = result
elif result:
items = [result]
if not items:
log("No items to delete", file=sys.stderr)
return 1
success_count = 0
for item in items:
if _process_single_item(item, override_hash, conserve, lib_root, reason, config):
success_count += 1
return 0 if success_count > 0 else 1
CMDLET = Cmdlet(
name="delete-file",

View File

@@ -8,7 +8,7 @@ import models
import pipeline as ctx
from helper import hydrus as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, normalize_hash, parse_tag_arguments
from helper.logger import log
from helper.logger import debug, log
CMDLET = Cmdlet(
@@ -68,6 +68,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# @5 or @{2,5,8} to delete tags from ResultTable by index
tags_from_at_syntax = []
hash_from_at_syntax = None
file_path_from_at_syntax = None
if rest and str(rest[0]).startswith("@"):
selector_arg = str(rest[0])
@@ -100,6 +101,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Also get hash from first item for consistency
if not hash_from_at_syntax:
hash_from_at_syntax = getattr(item, 'hash_hex', None)
if not file_path_from_at_syntax:
file_path_from_at_syntax = getattr(item, 'file_path', None)
if not tags_from_at_syntax:
log(f"No tags found at indices: {indices}")
@@ -112,108 +115,165 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1
# Handle @N selection which creates a list - extract the first item
if isinstance(result, list) and len(result) > 0:
# If we have a list of TagItems, we want to process ALL of them if no args provided
# This handles: delete-tag @1 (where @1 expands to a list containing one TagItem)
if not args and hasattr(result[0], '__class__') and result[0].__class__.__name__ == 'TagItem':
# We will extract tags from the list later
pass
else:
result = result[0]
# If we have a list of TagItems, we want to process ALL of them if no args provided
# This handles: delete-tag @1 (where @1 expands to a list containing one TagItem)
# Also handles: delete-tag @1,2 (where we want to delete tags from multiple files)
# Determine tags and hash to use
tags: list[str] = []
hash_hex = None
# Normalize result to a list for processing
items_to_process = []
if isinstance(result, list):
items_to_process = result
elif result:
items_to_process = [result]
# If we have TagItems and no args, we are deleting the tags themselves
# If we have Files (or other objects) and args, we are deleting tags FROM those files
# Check if we are in "delete selected tags" mode (TagItems)
is_tag_item_mode = (items_to_process and hasattr(items_to_process[0], '__class__') and
items_to_process[0].__class__.__name__ == 'TagItem')
if is_tag_item_mode:
# Collect all tags to delete from the TagItems
# Group by hash/file_path to batch operations if needed, or just process one by one
# For simplicity, we'll process one by one or group by file
pass
else:
# "Delete tags from files" mode
# We need args (tags to delete)
if not args and not tags_from_at_syntax:
log("Requires at least one tag argument when deleting from files")
return 1
# Process each item
success_count = 0
# If we have tags from @ syntax (e.g. delete-tag @{1,2}), we ignore the piped result for tag selection
# but we might need the piped result for the file context if @ selection was from a Tag table
# Actually, the @ selection logic above already extracted tags.
if tags_from_at_syntax:
# Use tags extracted from @ syntax
# Special case: @ selection of tags.
# We already extracted tags and hash/path.
# Just run the deletion once using the extracted info.
# This preserves the existing logic for @ selection.
tags = tags_from_at_syntax
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(hash_from_at_syntax)
log(f"[delete_tag] Using @ syntax extraction: {len(tags)} tag(s) to delete: {tags}")
elif isinstance(result, list) and result and hasattr(result[0], '__class__') and result[0].__class__.__name__ == 'TagItem':
# Got a list of TagItems (e.g. from delete-tag @1)
tags = [getattr(item, 'tag_name') for item in result if getattr(item, 'tag_name', None)]
# Use hash from first item
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(result[0], "hash_hex", None))
elif result and hasattr(result, '__class__') and result.__class__.__name__ == 'TagItem':
# Got a piped TagItem - delete this specific tag
tag_name = getattr(result, 'tag_name', None)
if tag_name:
tags = [tag_name]
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(result, "hash_hex", None))
file_path = file_path_from_at_syntax
if _process_deletion(tags, hash_hex, file_path, config):
success_count += 1
else:
# Traditional mode - parse tag arguments
tags = parse_tag_arguments(rest)
hash_hex = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(result, "hash_hex", None))
# Process items from pipe (or single result)
# If args are provided, they are the tags to delete from EACH item
# If items are TagItems and no args, the tag to delete is the item itself
tags_arg = parse_tag_arguments(rest)
for item in items_to_process:
tags_to_delete = []
item_hash = normalize_hash(override_hash) if override_hash else normalize_hash(getattr(item, "hash_hex", None))
item_path = getattr(item, "path", None) or getattr(item, "file_path", None) or getattr(item, "target", None)
# If result is a dict (e.g. from search-file), try getting path from keys
if not item_path and isinstance(item, dict):
item_path = item.get("path") or item.get("file_path") or item.get("target")
item_source = getattr(item, "source", None)
if hasattr(item, '__class__') and item.__class__.__name__ == 'TagItem':
# It's a TagItem
if tags_arg:
# User provided tags to delete FROM this file (ignoring the tag name in the item?)
# Or maybe they want to delete the tag in the item AND the args?
# Usually if piping TagItems, we delete THOSE tags.
# If args are present, maybe we should warn?
# For now, if args are present, assume they override or add to the tag item?
# Let's assume if args are present, we use args. If not, we use the tag name.
tags_to_delete = tags_arg
else:
tag_name = getattr(item, 'tag_name', None)
if tag_name:
tags_to_delete = [tag_name]
else:
# It's a File or other object
if tags_arg:
tags_to_delete = tags_arg
else:
# No tags provided for a file object - skip or error?
# We already logged an error if no args and not TagItem mode globally,
# but inside the loop we might have mixed items? Unlikely.
continue
if tags_to_delete and (item_hash or item_path):
if _process_deletion(tags_to_delete, item_hash, item_path, config, source=item_source):
success_count += 1
if success_count > 0:
return 0
return 1
def _process_deletion(tags: list[str], hash_hex: str | None, file_path: str | None, config: Dict[str, Any], source: str | None = None) -> bool:
"""Helper to execute the deletion logic for a single target."""
if not tags:
log("No valid tags were provided")
return 1
return False
if not hash_hex and not file_path:
log("Item does not include a hash or file path")
return False
# Handle local file tag deletion
if file_path and (source == "local" or (not hash_hex and source != "hydrus")):
try:
from helper.local_library import LocalLibraryDB
from pathlib import Path
path_obj = Path(file_path)
if not path_obj.exists():
log(f"File not found: {file_path}")
return False
# Try to get local storage path from config
from config import get_local_storage_path
local_root = get_local_storage_path(config)
if not local_root:
# Fallback: assume file is in a library root or use its parent
local_root = path_obj.parent
db = LocalLibraryDB(local_root)
db.remove_tags(path_obj, tags)
debug(f"Removed {len(tags)} tag(s) from {path_obj.name} (local)")
return True
except Exception as exc:
log(f"Failed to remove local tags: {exc}")
return False
# Hydrus deletion logic
if not hash_hex:
log("Selected result does not include a hash")
return 1
return False
try:
service_name = hydrus_wrapper.get_tag_service_name(config)
except Exception as exc:
log(f"Failed to resolve tag service: {exc}")
return 1
try:
client = hydrus_wrapper.get_client(config)
except Exception as exc:
log(f"Hydrus client unavailable: {exc}")
return 1
if client is None:
log("Hydrus client unavailable")
return 1
if client is None:
log("Hydrus client unavailable")
return False
debug(f"Sending deletion request: hash={hash_hex}, tags={tags}, service={service_name}")
client.delete_tags(hash_hex, tags, service_name)
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
debug(f"Removed {len(tags)} tag(s) from {preview} via '{service_name}'.")
return True
log(f"[delete_tag] Sending deletion request: hash={hash_hex}, tags={tags}, service={service_name}")
try:
result = client.delete_tags(hash_hex, tags, service_name)
log(f"[delete_tag] Hydrus response: {result}")
except Exception as exc:
log(f"Hydrus del-tag failed: {exc}")
return 1
preview = hash_hex[:12] + ('' if len(hash_hex) > 12 else '')
log(f"Removed {len(tags)} tag(s) from {preview} via '{service_name}'.")
# Re-fetch and emit updated tags after deletion
try:
payload = client.fetch_file_metadata(hashes=[str(hash_hex)], include_service_keys_to_tags=True, include_file_urls=False)
items = payload.get("metadata") if isinstance(payload, dict) else None
if isinstance(items, list) and items:
meta = items[0] if isinstance(items[0], dict) else None
if isinstance(meta, dict):
# Extract tags from updated metadata
from cmdlets.get_tag import _extract_my_tags_from_hydrus_meta, TagItem
service_key = hydrus_wrapper.get_tag_service_key(client, service_name)
updated_tags = _extract_my_tags_from_hydrus_meta(meta, service_key, service_name)
# Emit updated tags as TagItem objects
from result_table import ResultTable
table = ResultTable("Tags", max_columns=2)
tag_items = []
for idx, tag_name in enumerate(updated_tags, start=1):
tag_item = TagItem(
tag_name=tag_name,
tag_index=idx,
hash_hex=hash_hex,
source="hydrus",
service_name=service_name,
)
tag_items.append(tag_item)
table.add_result(tag_item)
ctx.emit(tag_item)
# Store items for @ selection in next command (CLI will handle table management)
# Don't call set_last_result_table so we don't pollute history or table context
except Exception as exc:
log(f"Warning: Could not fetch updated tags after deletion: {exc}", file=__import__('sys').stderr)
return 0
return False

View File

@@ -1058,11 +1058,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
debug(f"Torrent/magnet added: {arg[:50]}...")
elif _is_torrent_file_or_url(arg):
# Handle .torrent files and URLs
log(f"Processing torrent file/URL: {arg}", flush=True)
debug(f"Processing torrent file/URL: {arg}")
magnet = _process_torrent_input(arg)
if magnet and magnet.lower().startswith('magnet:'):
urls_to_download.append(magnet)
log(f"✓ Converted to magnet: {magnet[:70]}...", flush=True)
debug(f"✓ Converted to magnet: {magnet[:70]}...")
elif magnet:
urls_to_download.append(magnet)
else:
@@ -1081,17 +1081,17 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
line = line.strip()
if line and line.lower().startswith(('http://', 'https://')):
urls_to_download.append(line)
log(f"Loaded URLs from file: {arg}", flush=True)
debug(f"Loaded URLs from file: {arg}")
except Exception as e:
log(f"Error reading file {arg}: {e}", file=sys.stderr)
else:
log(f"Ignored argument: {arg}", file=sys.stderr)
debug(f"Ignored argument: {arg}")
# Item selection (for playlists/formats)
# Note: -item flag is deprecated in favor of @N pipeline selection, but kept for compatibility
playlist_items = parsed.get("item")
if playlist_items:
log(f"Item selection: {playlist_items}", flush=True)
debug(f"Item selection: {playlist_items}")
@@ -1149,7 +1149,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if isinstance(item, dict) and item.get('__playlist_url'):
playlist_url = item.get('__playlist_url')
item_num = item.get('__playlist_item', 1)
log(f"📍 Playlist item from add-file: #{item_num}", flush=True)
debug(f"📍 Playlist item from add-file: #{item_num}")
# Add to download list with marker
urls_to_download.append({
'__playlist_url': playlist_url,
@@ -1166,7 +1166,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if playlist_url:
# Playlist item selected - need to download this specific track
log(f"📍 Playlist item selected: #{item_num} - {item.get('title', 'Unknown')}", flush=True)
debug(f"📍 Playlist item selected: #{item_num} - {item.get('title', 'Unknown')}")
# Add to download list - the playlist will be probed and item extracted
# Store with special marker so we know which item to select
urls_to_download.append({
@@ -1177,14 +1177,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# ====== CHECK FOR FORMAT SELECTION RESULT ======
if isinstance(item, dict) and item.get('format_id') is not None and item.get('source_url'):
log(f"🎬 Format selected from pipe: {item.get('format_id')}", flush=True)
log(f" Source URL: {item.get('source_url')}", flush=True)
debug(f"🎬 Format selected from pipe: {item.get('format_id')}")
debug(f" Source URL: {item.get('source_url')}")
# Store as dict so we can extract format_id + source_url during download
urls_to_download.append(item)
continue
elif hasattr(item, 'format_id') and hasattr(item, 'source_url') and item.format_id is not None:
log(f"🎬 Format selected from pipe: {item.format_id}", flush=True)
log(f" Source URL: {item.source_url}", flush=True)
debug(f"🎬 Format selected from pipe: {item.format_id}")
debug(f" Source URL: {item.source_url}")
urls_to_download.append({
'format_id': item.format_id,
'source_url': item.source_url,
@@ -1204,9 +1204,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
isbn = metadata.get('isbn') or item.get('isbn')
olid = metadata.get('olid') or item.get('olid')
log(f"[search-result] OpenLibrary: '{title}'", flush=True)
debug(f"[search-result] OpenLibrary: '{title}'")
if isbn:
log(f" ISBN: {isbn}", flush=True)
debug(f" ISBN: {isbn}")
# Check if book is borrowable from ebook_access field or status
ebook_access = metadata.get('ebook_access') or item.get('ebook_access', '')
@@ -1217,8 +1217,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
is_borrowable = _is_openlibrary_downloadable(ebook_access, status_text)
if is_borrowable:
log(f" ✓ Available for borrowing on Archive.org", flush=True)
log(f" → Queued for auto-borrowing...", flush=True)
debug(f" ✓ Available for borrowing on Archive.org")
debug(f" → Queued for auto-borrowing...")
# Queue borrow request as special dict object
# We need OCAID (Archive.org ID), not just numeric OLID
ocaid = archive_id
@@ -1233,7 +1233,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
ol_data = r.json()
ocaid = ol_data.get('ocaid')
except Exception as e:
log(f" ⚠ Could not fetch OCAID from OpenLibrary: {e}", file=sys.stderr)
debug(f" ⚠ Could not fetch OCAID from OpenLibrary: {e}")
if ocaid:
urls_to_download.append({
@@ -1246,7 +1246,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
else:
# OCAID not found - book claims borrowable but not on Archive.org
# Fall back to LibGen search instead
log(f" ⚠ Book marked borrowable but not found on Archive.org", file=sys.stderr)
debug(f" ⚠ Book marked borrowable but not found on Archive.org")
if isbn:
try:
from helper.search_provider import get_provider
@@ -1258,19 +1258,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
url = libgen_result.get('target') if isinstance(libgen_result, dict) else getattr(libgen_result, 'target', None)
if url:
urls_to_download.append(url)
log(f" ✓ Found on LibGen instead", flush=True)
debug(f" ✓ Found on LibGen instead")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ LibGen provider not available", file=sys.stderr)
debug(f" ⚠ LibGen provider not available")
except Exception as e:
log(f" ✗ Error searching LibGen: {e}", file=sys.stderr)
debug(f" ✗ Error searching LibGen: {e}")
else:
# Book is NOT borrowable - route to LibGen
if isbn:
log(f" ⚠ Not available on Archive.org - attempting LibGen...", flush=True)
debug(f" ⚠ Not available on Archive.org - attempting LibGen...")
try:
from helper.search_provider import get_provider
libgen_provider = get_provider("libgen", config)
@@ -1281,21 +1281,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
url = libgen_result.get('target') if isinstance(libgen_result, dict) else getattr(libgen_result, 'target', None)
if url:
urls_to_download.append(url)
log(f" ✓ Found on LibGen", flush=True)
debug(f" ✓ Found on LibGen")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ Not found on LibGen", flush=True)
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ⚠ Not found on LibGen")
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
else:
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
except Exception as e:
log(f" ⚠ Could not search LibGen: {e}", file=sys.stderr)
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ⚠ Could not search LibGen: {e}")
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
else:
log(f" ⚠ ISBN not available", flush=True)
log(f" ▶ Visit: {item.get('target', 'https://openlibrary.org')}", flush=True)
log(f" ▶ Or find ISBN and use: search-file -provider libgen 'isbn:\"<ISBN>\"'", flush=True)
debug(f" ⚠ ISBN not available")
debug(f" ▶ Visit: {item.get('target', 'https://openlibrary.org')}")
debug(f" ▶ Or find ISBN and use: search-file -provider libgen 'isbn:\"<ISBN>\"'")
elif origin == 'soulseek':
# Handle Soulseek downloads using the provider
metadata = item.get('full_metadata', {}) if isinstance(item.get('full_metadata'), dict) else {}
@@ -1350,18 +1350,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
)
pipeline_context.emit(result_dict)
else:
log(f" ✗ Download failed (peer may be offline)", file=sys.stderr)
debug(f" ✗ Download failed (peer may be offline)")
if db:
db.append_worker_stdout(worker_id, f"✗ Download failed for {title}")
log(f" ▶ Try another result: search-file -provider soulseek \"...\" | @2 | download-data", flush=True)
debug(f" ▶ Try another result: search-file -provider soulseek \"...\" | @2 | download-data")
except Exception as e:
log(f" ✗ Download error: {e}", file=sys.stderr)
debug(f" ✗ Download error: {e}")
if db:
db.append_worker_stdout(worker_id, f"✗ Error: {e}")
log(f" ▶ Alternative: search-soulseek -download \"{title}\" -storage <location>", flush=True)
debug(f" ▶ Alternative: search-soulseek -download \"{title}\" -storage <location>")
else:
log(f"[search-result] Soulseek: '{title}'", flush=True)
log(f" ⚠ Missing download info (username/filename)", flush=True)
debug(f"[search-result] Soulseek: '{title}'")
debug(f" ⚠ Missing download info (username/filename)")
if db:
db.append_worker_stdout(worker_id, f"⚠ Missing download info for {title}")
elif origin == 'libgen':
@@ -1380,17 +1380,17 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
'book_id': book_id,
}
urls_to_download.append(url_entry)
log(f"[search-result] LibGen: '{title}'", flush=True)
log(f" ✓ Queued for download", flush=True)
debug(f"[search-result] LibGen: '{title}'")
debug(f" ✓ Queued for download")
if mirrors:
log(f" Mirrors available: {len(mirrors)}", flush=True)
debug(f" Mirrors available: {len(mirrors)}")
elif origin == 'debrid':
# Debrid results can use download-data
url = item.get('target')
if url:
urls_to_download.append(str(url))
log(f"[search-result] Debrid: '{title}'", flush=True)
log(f" ✓ Queued for download", flush=True)
debug(f"[search-result] Debrid: '{title}'")
debug(f" ✓ Queued for download")
else:
# Regular fields for non-search results
url = item.get('url') or item.get('link') or item.get('href') or item.get('target')
@@ -1407,9 +1407,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
isbn = metadata.get('isbn') or getattr(item, 'isbn', None)
olid = metadata.get('olid') or getattr(item, 'olid', None)
log(f"[search-result] OpenLibrary: '{title}'", flush=True)
debug(f"[search-result] OpenLibrary: '{title}'")
if isbn:
log(f" ISBN: {isbn}", flush=True)
debug(f" ISBN: {isbn}")
# Check if book is borrowable from ebook_access field or status
ebook_access = metadata.get('ebook_access') or getattr(item, 'ebook_access', '')
@@ -1421,8 +1421,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if is_borrowable:
# Book IS borrowable on Archive.org
log(f" ✓ Available for borrowing on Archive.org", flush=True)
log(f" → Queued for auto-borrowing...", flush=True)
debug(f" ✓ Available for borrowing on Archive.org")
debug(f" → Queued for auto-borrowing...")
# Queue borrow request as special dict object
ocaid = archive_id
if not ocaid and isbn:
@@ -1434,7 +1434,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
ol_data = r.json()
ocaid = ol_data.get('ocaid')
except Exception as e:
log(f" ⚠ Could not fetch OCAID from OpenLibrary: {e}", file=sys.stderr)
debug(f" ⚠ Could not fetch OCAID from OpenLibrary: {e}")
if ocaid:
urls_to_download.append({
'__borrow_request__': True,
@@ -1446,7 +1446,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
else:
# OCAID not found - book claims borrowable but not on Archive.org
# Fall back to LibGen search instead
log(f" ⚠ No Archive.org ID found - attempting LibGen instead...", file=sys.stderr)
debug(f" ⚠ No Archive.org ID found - attempting LibGen instead...")
if isbn:
try:
from helper.search_provider import get_provider
@@ -1458,21 +1458,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
url = libgen_result.get('target') if isinstance(libgen_result, dict) else getattr(libgen_result, 'target', None)
if url:
urls_to_download.append(url)
log(f" ✓ Found on LibGen instead", flush=True)
debug(f" ✓ Found on LibGen instead")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ LibGen provider not available", file=sys.stderr)
debug(f" ⚠ LibGen provider not available")
except Exception as e:
log(f" ✗ Error searching LibGen: {e}", file=sys.stderr)
debug(f" ✗ Error searching LibGen: {e}")
else:
log(f" ⚠ ISBN not available for LibGen fallback", file=sys.stderr)
debug(f" ⚠ ISBN not available for LibGen fallback")
else:
# Book is NOT borrowable - route to LibGen
if isbn:
log(f" ⚠ Not available on Archive.org - attempting LibGen...", flush=True)
debug(f" ⚠ Not available on Archive.org - attempting LibGen...")
try:
from helper.search_provider import get_provider
libgen_provider = get_provider("libgen", config)
@@ -1483,21 +1483,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
url = libgen_result.get('target') if isinstance(libgen_result, dict) else getattr(libgen_result, 'target', None)
if url:
urls_to_download.append(url)
log(f" ✓ Found on LibGen", flush=True)
debug(f" ✓ Found on LibGen")
else:
log(f" ⚠ Not found on LibGen", file=sys.stderr)
debug(f" ⚠ Not found on LibGen")
else:
log(f" ⚠ Not found on LibGen", flush=True)
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ⚠ Not found on LibGen")
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
else:
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
except Exception as e:
log(f" ⚠ Could not search LibGen: {e}", file=sys.stderr)
log(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data", flush=True)
debug(f" ⚠ Could not search LibGen: {e}")
debug(f" ▶ To search LibGen: search-file -provider libgen 'isbn:{isbn}' | @1 | download-data")
else:
log(f" ⚠ ISBN not available", flush=True)
log(f" ▶ Visit: {getattr(item, 'target', 'https://openlibrary.org')}", flush=True)
log(f" ▶ Or find ISBN and use: search-file -provider libgen 'isbn:\"<ISBN>\"'", flush=True)
debug(f" ⚠ ISBN not available")
debug(f" ▶ Visit: {getattr(item, 'target', 'https://openlibrary.org')}")
debug(f" ▶ Or find ISBN and use: search-file -provider libgen 'isbn:\"<ISBN>\"'")
elif origin == 'soulseek':
# Handle Soulseek downloads using the provider
metadata = getattr(item, 'full_metadata', {}) if isinstance(getattr(item, 'full_metadata', None), dict) else {}
@@ -1510,8 +1510,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
import asyncio
from helper.search_provider import SoulSeekProvider
provider = SoulSeekProvider(config)
log(f"[search-result] Soulseek: '{title}'", flush=True)
log(f" ▶ Downloading from {username}...", flush=True)
debug(f"[search-result] Soulseek: '{title}'")
debug(f" ▶ Downloading from {username}...")
if db:
db.append_worker_stdout(worker_id, f"Downloading from Soulseek: {title} (from {username})")
@@ -1532,7 +1532,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if success:
downloaded_file = Path(provider.DOWNLOAD_DIR) / Path(filename).name
if downloaded_file.exists():
log(f" ✓ Downloaded: {downloaded_file.name}", flush=True)
debug(f" ✓ Downloaded: {downloaded_file.name}")
files_downloaded_directly += 1
if db:
db.append_worker_stdout(worker_id, f"✓ Downloaded: {downloaded_file.name}")
@@ -1552,18 +1552,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
)
pipeline_context.emit(result_dict)
else:
log(f" ✗ Download failed (peer may be offline)", file=sys.stderr)
debug(f" ✗ Download failed (peer may be offline)")
if db:
db.append_worker_stdout(worker_id, f"✗ Download failed for {title}")
log(f" ▶ Try another result: search-file -provider soulseek \"...\" | @2 | download-data", flush=True)
debug(f" ▶ Try another result: search-file -provider soulseek \"...\" | @2 | download-data")
except Exception as e:
log(f" ✗ Download error: {e}", file=sys.stderr)
debug(f" ✗ Download error: {e}")
if db:
db.append_worker_stdout(worker_id, f"✗ Error: {e}")
log(f" ▶ Alternative: search-soulseek -download \"{title}\" -storage <location>", flush=True)
debug(f" ▶ Alternative: search-soulseek -download \"{title}\" -storage <location>")
else:
log(f"[search-result] Soulseek: '{title}'", flush=True)
log(f" ⚠ Missing download info (username/filename)", flush=True)
debug(f"[search-result] Soulseek: '{title}'")
debug(f" ⚠ Missing download info (username/filename)")
if db:
db.append_worker_stdout(worker_id, f"⚠ Missing download info for {title}")
elif origin == 'libgen':
@@ -1592,15 +1592,15 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
urls_to_download.append(str(url))
if not urls_to_download and files_downloaded_directly == 0:
log(f"No downloadable URLs found", file=sys.stderr)
debug(f"No downloadable URLs found")
return 1
log(f"Processing {len(urls_to_download)} URL(s)", flush=True)
debug(f"Processing {len(urls_to_download)} URL(s)")
for i, u in enumerate(urls_to_download, 1):
if isinstance(u, dict):
log(f" [{i}] Format: {u.get('format_id', '?')} from {u.get('source_url', '?')[:60]}...", flush=True)
debug(f" [{i}] Format: {u.get('format_id', '?')} from {u.get('source_url', '?')[:60]}...")
else:
log(f" [{i}] URL: {str(u)[:60]}...", flush=True)
debug(f" [{i}] URL: {str(u)[:60]}...")
# ========================================================================
# RESOLVE OUTPUT DIRECTORY
@@ -1611,8 +1611,24 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Priority 1: --storage flag
if storage_location:
try:
final_output_dir = SharedArgs.resolve_storage(storage_location)
log(f"Using storage location: {storage_location}{final_output_dir}", flush=True)
# For 'local' storage, check config first before using default
if storage_location.lower() == 'local':
from config import get_local_storage_path
try:
configured_path = get_local_storage_path(config)
if configured_path:
final_output_dir = configured_path
debug(f"Using configured local storage path: {final_output_dir}")
else:
final_output_dir = SharedArgs.resolve_storage(storage_location)
debug(f"Using default storage location: {storage_location}{final_output_dir}")
except Exception as exc:
log(f"⚠️ Error reading local storage config: {exc}", file=sys.stderr)
final_output_dir = SharedArgs.resolve_storage(storage_location)
debug(f"Falling back to default storage location: {storage_location}{final_output_dir}")
else:
final_output_dir = SharedArgs.resolve_storage(storage_location)
debug(f"Using storage location: {storage_location}{final_output_dir}")
except ValueError as e:
log(str(e), file=sys.stderr)
return 1
@@ -1621,7 +1637,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if final_output_dir is None and resolve_output_dir is not None:
try:
final_output_dir = resolve_output_dir(config)
log(f"Using config resolver: {final_output_dir}", flush=True)
debug(f"Using config resolver: {final_output_dir}")
except Exception:
pass
@@ -1629,14 +1645,14 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if final_output_dir is None and config and config.get("outfile"):
try:
final_output_dir = Path(config["outfile"]).expanduser()
log(f"Using config outfile: {final_output_dir}", flush=True)
debug(f"Using config outfile: {final_output_dir}")
except Exception:
pass
# Priority 5: Default (home/Videos)
if final_output_dir is None:
final_output_dir = Path.home() / "Videos"
log(f"Using default directory: {final_output_dir}", flush=True)
debug(f"Using default directory: {final_output_dir}")
# Ensure directory exists
try:
@@ -1664,7 +1680,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
current_format_selector = format_selector
actual_url = url
if isinstance(url, dict) and url.get('format_id') and url.get('source_url'):
log(f"🎬 Format selected: {url.get('format_id')}", flush=True)
debug(f"🎬 Format selected: {url.get('format_id')}")
format_id = url.get('format_id')
current_format_selector = format_id
@@ -1674,7 +1690,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if vcodec and vcodec != "none" and (not acodec or acodec == "none"):
# Video-only format, add bestaudio automatically
current_format_selector = f"{format_id}+bestaudio"
log(f" Video-only format detected, automatically adding bestaudio", flush=True)
debug(f" Video-only format detected, automatically adding bestaudio")
actual_url = url.get('source_url')
url = actual_url # Use the actual URL for further processing
@@ -1688,15 +1704,15 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
book_id = url.get('book_id')
if not book_id:
log(f" ✗ Missing book ID for borrowing", file=sys.stderr)
debug(f" ✗ Missing book ID for borrowing")
exit_code = 1
continue
title_val = url.get('title', 'Unknown Book')
book_id_str = str(book_id)
log(f"[auto-borrow] Starting borrow for: {title_val}", flush=True)
log(f" Book ID: {book_id_str}", flush=True)
debug(f"[auto-borrow] Starting borrow for: {title_val}")
debug(f" Book ID: {book_id_str}")
# Get Archive.org credentials
email, password = credential_openlibrary(config)
@@ -1708,33 +1724,33 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Attempt to borrow and download
try:
log(f" → Logging into Archive.org...", flush=True)
debug(f" → Logging into Archive.org...")
from helper.archive_client import login
import requests
try:
session = login(email, password)
except requests.exceptions.Timeout:
log(f" ✗ Timeout logging into Archive.org (server not responding)", file=sys.stderr)
debug(f" ✗ Timeout logging into Archive.org (server not responding)")
exit_code = 1
continue
except requests.exceptions.RequestException as e:
log(f" ✗ Error connecting to Archive.org: {e}", file=sys.stderr)
debug(f" ✗ Error connecting to Archive.org: {e}")
exit_code = 1
continue
log(f" → Borrowing book...", flush=True)
debug(f" → Borrowing book...")
try:
session = loan(session, book_id_str, verbose=True)
except requests.exceptions.Timeout:
log(f" ✗ Timeout while borrowing (server not responding)", file=sys.stderr)
debug(f" ✗ Timeout while borrowing (server not responding)")
exit_code = 1
continue
except requests.exceptions.RequestException as e:
log(f" ✗ Error while borrowing: {e}", file=sys.stderr)
debug(f" ✗ Error while borrowing: {e}")
exit_code = 1
continue
log(f" → Extracting page information...", flush=True)
debug(f" → Extracting page information...")
# Try both URL formats
book_urls = [
f"https://archive.org/borrow/{book_id_str}",
@@ -1749,24 +1765,24 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
try:
title, links, metadata = get_book_infos(session, book_url)
if title and links:
log(f" → Found {len(links)} pages", flush=True)
debug(f" → Found {len(links)} pages")
break
except requests.exceptions.Timeout:
last_error = "Timeout while extracting pages"
log(f" ⚠ Timeout while extracting from {book_url}", flush=True)
debug(f" ⚠ Timeout while extracting from {book_url}")
continue
except Exception as e:
last_error = str(e)
log(f" ⚠ Failed to extract from {book_url}: {e}", flush=True)
debug(f" ⚠ Failed to extract from {book_url}: {e}")
continue
if not links:
log(f" ✗ Could not extract book pages (Last error: {last_error})", file=sys.stderr)
debug(f" ✗ Could not extract book pages (Last error: {last_error})")
exit_code = 1
continue
# Download pages
log(f" → Downloading {len(links)} pages...", flush=True)
debug(f" → Downloading {len(links)} pages...")
with tempfile.TemporaryDirectory() as temp_dir:
# download(session, n_threads, directory, links, scale, book_id)
images = download(
@@ -1779,16 +1795,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
)
if not images:
log(f" ✗ No pages downloaded", file=sys.stderr)
debug(f" ✗ No pages downloaded")
exit_code = 1
continue
log(f" ✓ Downloaded {len(images)} pages", flush=True)
debug(f" ✓ Downloaded {len(images)} pages")
# Try to merge into PDF
try:
import img2pdf
log(f" → Merging pages into PDF...", flush=True)
debug(f" → Merging pages into PDF...")
filename = title if title else f"book_{book_id_str}"
filename = "".join(c for c in filename if c.isalnum() or c in (' ', '.', '-'))[:100]
@@ -1805,7 +1821,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
with open(output_path, 'wb') as f:
f.write(pdf_content)
log(f" ✓ Successfully borrowed and saved to: {output_path}", flush=True)
debug(f" ✓ Successfully borrowed and saved to: {output_path}")
downloaded_files.append(str(output_path))
# Emit result for downstream cmdlets
@@ -1836,7 +1852,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
pipeline_context.emit(pipe_obj)
exit_code = 0
except ImportError:
log(f" ⚠ img2pdf not available - saving pages as collection", file=sys.stderr)
debug(f" ⚠ img2pdf not available - saving pages as collection")
# Just copy images to output dir
filename = title if title else f"book_{book_id_str}"
filename = "".join(c for c in filename if c.isalnum() or c in (' ', '.', '-'))[:100]
@@ -1847,7 +1863,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
i += 1
shutil.copytree(temp_dir, str(output_dir))
log(f" ✓ Successfully borrowed and saved to: {output_dir}", flush=True)
debug(f" ✓ Successfully borrowed and saved to: {output_dir}")
downloaded_files.append(str(output_dir))
# Emit result for downstream cmdlets
@@ -1877,7 +1893,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
exit_code = 0
except Exception as e:
log(f" ✗ Borrow/download failed: {e}", file=sys.stderr)
debug(f" ✗ Borrow/download failed: {e}")
import traceback
traceback.print_exc()
exit_code = 1
@@ -1885,11 +1901,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
continue # Skip normal URL handling
except ImportError as e:
log(f" ✗ Archive.org tools not available: {e}", file=sys.stderr)
debug(f" ✗ Archive.org tools not available: {e}")
exit_code = 1
continue
except Exception as e:
log(f" ✗ Auto-borrow error: {e}", file=sys.stderr)
debug(f" ✗ Auto-borrow error: {e}")
import traceback
traceback.print_exc()
exit_code = 1
@@ -1905,7 +1921,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
book_id = url.get('book_id', '')
if not primary_url:
log(f"Skipping libgen entry: no primary URL", file=sys.stderr)
debug(f"Skipping libgen entry: no primary URL")
exit_code = 1
continue
@@ -1916,11 +1932,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Remove duplicates while preserving order
mirrors_to_try = list(dict.fromkeys(mirrors_to_try))
log(f"🔄 LibGen download with mirror fallback (book_id: {book_id})", flush=True)
log(f" Primary: {primary_url[:80]}...", flush=True)
debug(f"🔄 LibGen download with mirror fallback (book_id: {book_id})")
debug(f" Primary: {primary_url[:80]}...")
if len(mirrors_to_try) > 1:
log(f" {len(mirrors_to_try) - 1} alternative mirror(s) available", flush=True)
debug(f" {len(mirrors_to_try) - 1} alternative mirror(s) available")
# Resolve cookies path
final_cookies_path_libgen = None
@@ -1941,7 +1957,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
for mirror_idx, mirror_url in enumerate(mirrors_to_try, 1):
try:
if mirror_idx > 1:
log(f" → Trying mirror #{mirror_idx}: {mirror_url[:80]}...", flush=True)
debug(f" → Trying mirror #{mirror_idx}: {mirror_url[:80]}...")
# Use libgen_service's download_from_mirror for proper libgen handling
from helper.libgen_service import download_from_mirror
@@ -1954,12 +1970,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
success = download_from_mirror(
mirror_url=mirror_url,
output_path=file_path,
log_info=lambda msg: log(f" {msg}", flush=True),
log_error=lambda msg: log(f"{msg}", file=sys.stderr)
log_info=lambda msg: debug(f" {msg}"),
log_error=lambda msg: debug(f"{msg}")
)
if success and file_path.exists():
log(f" ✓ Downloaded successfully from mirror #{mirror_idx}", flush=True)
debug(f" ✓ Downloaded successfully from mirror #{mirror_idx}")
successful_mirror = mirror_url
download_succeeded = True
@@ -1984,9 +2000,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
except Exception as e:
last_error = str(e)
if mirror_idx == 1:
log(f" ⚠ Primary mirror failed: {e}", flush=True)
debug(f" ⚠ Primary mirror failed: {e}")
else:
log(f" ⚠ Mirror #{mirror_idx} failed: {e}", flush=True)
debug(f" ⚠ Mirror #{mirror_idx} failed: {e}")
if not download_succeeded:
log(f" ✗ All mirrors failed. Last error: {last_error}", file=sys.stderr)
@@ -1998,7 +2014,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
continue # Skip to next URL
except Exception as e:
log(f" ✗ LibGen mirror fallback error: {e}", file=sys.stderr)
debug(f" ✗ LibGen mirror fallback error: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
exit_code = 1
@@ -2010,20 +2026,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if isinstance(url, dict) and url.get('__playlist_url'):
playlist_url = url.get('__playlist_url')
item_num = url.get('__playlist_item', 1)
log(f"📍 Handling selected playlist item #{item_num}", flush=True)
debug(f"📍 Handling selected playlist item #{item_num}")
# Convert to actual URL and set playlist_items to download only this item
url = playlist_url
playlist_items = str(item_num)
# Fall through to normal handling below
else:
log(f"Skipping invalid URL entry: {url}", file=sys.stderr)
debug(f"Skipping invalid URL entry: {url}")
continue
log(f"Probing URL: {url}", flush=True)
debug(f"Probing URL: {url}")
# ====== TORRENT MODE - INTERCEPT BEFORE NORMAL DOWNLOAD ======
if torrent_mode or url.lower().startswith('magnet:'):
log(f"🧲 Torrent/magnet mode - spawning background worker...", flush=True)
debug(f"🧲 Torrent/magnet mode - spawning background worker...")
try:
# Get API key from config
@@ -2051,9 +2067,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
description=f"Torrent/magnet download via AllDebrid",
pipe=pipeline_context.get_current_command_text()
)
log(f"✓ Worker created (ID: {worker_id})", flush=True)
debug(f"✓ Worker created (ID: {worker_id})")
except Exception as e:
log(f"⚠ Failed to create worker: {e}", file=sys.stderr)
debug(f"⚠ Failed to create worker: {e}")
worker_manager = None
# Spawn background thread to handle the download
@@ -2075,7 +2091,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
)
worker_thread.start()
log(f"✓ Background worker started (ID: {worker_id})", flush=True)
debug(f"✓ Background worker started (ID: {worker_id})")
# Emit worker info so user can track it
worker_info = {
@@ -2110,7 +2126,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
is_actual_playlist = False # Track if we have a real multi-item playlist
if probe_info:
log(f"✓ Probed: {probe_info.get('title', url)} ({probe_info.get('extractor', 'unknown')})")
debug(f"✓ Probed: {probe_info.get('title', url)} ({probe_info.get('extractor', 'unknown')})")
# If it's a playlist, show the result table and skip download for now
entries = probe_info.get("entries", [])
@@ -2118,9 +2134,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
is_actual_playlist = True # We have a real playlist with multiple items
# Playlist detected but NO selection provided
# Always show table for user to select items
log(f"📋 Found playlist with {len(entries)} items")
debug(f"📋 Found playlist with {len(entries)} items")
_show_playlist_table(url, probe_info)
log(f" Playlist displayed. To select items, use @* or @1,3,5-8 syntax after piping results")
debug(f" Playlist displayed. To select items, use @* or @1,3,5-8 syntax after piping results")
playlists_displayed += 1
continue # Skip to next URL - don't download playlist without selection
elif entries and playlist_items:
@@ -2130,13 +2146,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
expanded_items = _expand_playlist_selection(playlist_items, len(entries))
playlist_items = expanded_items
selected_playlist_entries = _select_playlist_entries(entries, playlist_items)
log(f"📋 Found playlist with {len(entries)} items - downloading selected: {playlist_items}")
debug(f"📋 Found playlist with {len(entries)} items - downloading selected: {playlist_items}")
else:
log(f"Single item: {probe_info.get('title', 'Unknown')}")
debug(f"Single item: {probe_info.get('title', 'Unknown')}")
# ====== FORMAT LISTING MODE ======
if list_formats_mode and isinstance(url, str) and url.startswith(('http://', 'https://')):
log(f"Fetching formats for: {url}", flush=True)
debug(f"Fetching formats for: {url}")
from helper.download import list_formats
from result_table import ResultTable
@@ -2209,7 +2225,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
"source_url": url,
"index": i,
})
log(f"Use @N syntax to select a format and download", flush=True)
debug(f"Use @N syntax to select a format and download")
else:
log(f"✗ No formats available for this URL", file=sys.stderr)
@@ -2224,7 +2240,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
from result_table import ResultTable
if is_url_supported_by_ytdlp(url):
log(f"Checking available formats for: {url}", flush=True)
debug(f"Checking available formats for: {url}")
all_formats = list_formats(url, no_playlist=is_youtube_url, playlist_items=playlist_items)
if all_formats:
@@ -2237,14 +2253,22 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
if 0 < idx <= len(formats):
fmt = formats[idx-1]
current_format_selector = fmt.get("format_id")
log(f"Selected format #{idx}: {current_format_selector}")
# If video-only format is selected, append +bestaudio to merge with best audio
vcodec = fmt.get("vcodec")
acodec = fmt.get("acodec")
if vcodec and vcodec != "none" and (not acodec or acodec == "none"):
current_format_selector = f"{current_format_selector}+bestaudio"
debug(f"Video-only format selected, appending bestaudio: {current_format_selector}")
debug(f"Selected format #{idx}: {current_format_selector}")
playlist_items = None # Clear so it doesn't affect download options
else:
log(f"Invalid format index: {idx}", file=sys.stderr)
elif len(formats) > 1:
# Multiple formats available
log(f"📊 Found {len(formats)} available formats for: {probe_info.get('title', 'Unknown')}", flush=True)
debug(f"📊 Found {len(formats)} available formats for: {probe_info.get('title', 'Unknown')}")
# Always show table for format selection via @N syntax
# Show table and wait for @N selection
@@ -2294,8 +2318,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
table.set_row_selection_args(i, ["-item", str(i + 1)])
# Display table and emit formats so they can be selected with @N
log(str(table), flush=True)
log(f"💡 Use @N syntax to select a format and download (e.g., @1)", flush=True)
debug(str(table))
debug(f"💡 Use @N syntax to select a format and download (e.g., @1)")
# Store table for @N expansion so CLI can reconstruct commands
pipeline_context.set_current_stage_table(table)
@@ -2317,7 +2341,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
formats_displayed = True # Mark that we displayed formats
continue # Skip download, user must select format via @N
log(f"Downloading: {url}", flush=True)
debug(f"Downloading: {url}")
# Resolve cookies path if specified
final_cookies_path = None
@@ -2362,19 +2386,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Check if this was a playlist download (is_actual_playlist tracks if we have a multi-item playlist)
if is_actual_playlist:
if not selected_playlist_entries:
log(
"⚠ Playlist metadata unavailable; cannot emit selected items for this stage.",
file=sys.stderr,
)
debug("⚠ Playlist metadata unavailable; cannot emit selected items for this stage.")
exit_code = 1
continue
matched_after, _ = _snapshot_playlist_paths(selected_playlist_entries, final_output_dir)
if not matched_after:
log(
"⚠ No playlist files found for the selected items after download.",
file=sys.stderr,
)
debug("⚠ No playlist files found for the selected items after download.")
exit_code = 1
continue
@@ -2389,9 +2407,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
emit_targets = new_playlist_files if new_playlist_files else matched_after
if new_playlist_files:
log(f"📋 Playlist download completed: {len(new_playlist_files)} new file(s)")
debug(f"📋 Playlist download completed: {len(new_playlist_files)} new file(s)")
else:
log(f"📁 Reusing {len(emit_targets)} cached playlist file(s)", flush=True)
debug(f"📁 Reusing {len(emit_targets)} cached playlist file(s)")
for playlist_file in emit_targets:
file_hash = _compute_file_hash(playlist_file)
@@ -2444,7 +2462,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
downloaded_files.append(file_path)
pipeline_context.emit(pipe_obj)
log(f"✓ Downloaded: {file_path}", flush=True)
debug(f"✓ Downloaded: {file_path}")
else:
log(f"Download returned no result for {url}", file=sys.stderr)
exit_code = 1
@@ -2458,20 +2476,56 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any], emit_results:
# Success if we downloaded files or displayed playlists/formats
if downloaded_files or files_downloaded_directly > 0:
total_files = len(downloaded_files) + files_downloaded_directly
log(f"✓ Successfully downloaded {total_files} file(s)", flush=True)
debug(f"✓ Successfully downloaded {total_files} file(s)")
# Create a result table for the downloaded files
# This ensures that subsequent @N commands select from these files
# instead of trying to expand the previous command (e.g. search-file)
if downloaded_files:
from result_table import ResultTable
table = ResultTable("Downloaded Files")
for i, file_path in enumerate(downloaded_files):
# Ensure file_path is a Path object
if isinstance(file_path, str):
file_path = Path(file_path)
row = table.add_row()
row.add_column("#", str(i + 1))
row.add_column("File", file_path.name)
row.add_column("Path", str(file_path))
try:
size_mb = file_path.stat().st_size / (1024*1024)
row.add_column("Size", f"{size_mb:.1f} MB")
except OSError:
row.add_column("Size", "?")
# Set selection args to just the file path (or index if we want item selection)
# For item selection fallback, we don't strictly need row args if source command is None
# But setting them helps if we want to support command expansion later
table.set_row_selection_args(i, [str(file_path)])
# Register the table but DO NOT set a source command
# This forces CLI to use item-based selection (filtering the pipe)
# instead of command expansion
pipeline_context.set_last_result_table_overlay(table, downloaded_files)
pipeline_context.set_current_stage_table(table)
# Also print the table so user sees what they got
log(str(table), flush=True)
if db:
db.update_worker_status(worker_id, 'completed')
return 0
if playlists_displayed:
log(f"✓ Displayed {playlists_displayed} playlist(s) for selection", flush=True)
debug(f"✓ Displayed {playlists_displayed} playlist(s) for selection")
if db:
db.update_worker_status(worker_id, 'completed')
db.close()
return 0 # Success - playlists shown
if formats_displayed:
log(f"✓ Format selection table displayed - use @N to select and download", flush=True)
debug(f"✓ Format selection table displayed - use @N to select and download")
if db:
db.update_worker_status(worker_id, 'completed')
db.close()

View File

@@ -6,13 +6,15 @@ import shutil as _shutil
import subprocess as _subprocess
import json
import sys
import platform
from helper.logger import log
from helper.logger import log, debug
import uuid as _uuid
import time as _time
from downlow_helpers.progress import print_progress, print_final_progress, format_size
from downlow_helpers.http_client import HTTPClient
from helper.progress import print_progress, print_final_progress
from helper.http_client import HTTPClient
from helper.mpv_ipc import get_ipc_pipe_path, send_to_mpv
import fnmatch as _fnmatch
from . import register
@@ -21,7 +23,7 @@ import pipeline as ctx
from helper import hydrus as hydrus_wrapper
from ._shared import Cmdlet, CmdletArg, normalize_hash, looks_like_hash, create_pipe_object_result
from config import resolve_output_dir, get_hydrus_url, get_hydrus_access_key
from downlow_helpers.alldebrid import AllDebridClient
from helper.alldebrid import AllDebridClient
@@ -248,138 +250,23 @@ def _is_playable_in_mpv(file_path_or_ext: str, mime_type: Optional[str] = None)
return False
def _get_fixed_ipc_pipe() -> str:
"""Get the fixed IPC pipe name for persistent MPV connection.
Uses a fixed name 'mpv-medeia-macina' so all playback sessions
connect to the same MPV window/process instead of creating new instances.
"""
import platform
if platform.system() == 'Windows':
return "\\\\.\\pipe\\mpv-medeia-macina"
else:
return "/tmp/mpv-medeia-macina.sock"
def _send_to_mpv_pipe(file_url: str, ipc_pipe: str, title: str, headers: Optional[Dict[str, str]] = None) -> bool:
"""Send loadfile command to existing MPV via IPC pipe.
Returns True if successfully sent to existing MPV, False if pipe unavailable.
"""
import json
import socket
import platform
try:
# Prepare commands
# Use set_property for headers as loadfile options can be unreliable via IPC
header_str = ""
if headers:
header_str = ",".join([f"{k}: {v}" for k, v in headers.items()])
# Command 1: Set headers (or clear them)
cmd_headers = {
"command": ["set_property", "http-header-fields", header_str],
"request_id": 0
}
# Command 2: Load file using memory:// M3U to preserve title
# Sanitize title to avoid breaking M3U format
safe_title = title.replace("\n", " ").replace("\r", "")
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{file_url}\n"
cmd_load = {
"command": ["loadfile", f"memory://{m3u_content}", "append-play"],
"request_id": 1
}
if platform.system() == 'Windows':
# Windows named pipes require special handling
try:
# Open in r+b to read response
with open(ipc_pipe, 'r+b', buffering=0) as pipe:
# Send headers
pipe.write((json.dumps(cmd_headers) + "\n").encode('utf-8'))
pipe.flush()
pipe.readline() # Consume response for headers
# Send loadfile
pipe.write((json.dumps(cmd_load) + "\n").encode('utf-8'))
pipe.flush()
# Read response
response_line = pipe.readline()
if response_line:
resp = json.loads(response_line.decode('utf-8'))
if resp.get('error') != 'success':
log(f"[get-file] MPV error: {resp.get('error')}", file=sys.stderr)
return False
log(f"[get-file] Sent to existing MPV: {title}", file=sys.stderr)
return True
except (OSError, IOError):
# Pipe not available
return False
else:
# Unix socket for Linux/macOS
if not hasattr(socket, 'AF_UNIX'):
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(ipc_pipe)
# Send headers
sock.sendall((json.dumps(cmd_headers) + "\n").encode('utf-8'))
sock.recv(4096) # Consume response
# Send loadfile
sock.sendall((json.dumps(cmd_load) + "\n").encode('utf-8'))
# Read response
try:
response_data = sock.recv(4096)
if response_data:
resp = json.loads(response_data.decode('utf-8'))
if resp.get('error') != 'success':
log(f"[get-file] MPV error: {resp.get('error')}", file=sys.stderr)
sock.close()
return False
except:
pass
sock.close()
log(f"[get-file] Sent to existing MPV: {title}", file=sys.stderr)
return True
except (OSError, socket.error, ConnectionRefusedError):
# Pipe doesn't exist or MPV not listening - will need to start new instance
return False
except Exception as e:
log(f"[get-file] IPC error: {e}", file=sys.stderr)
return False
def _play_in_mpv(file_url: str, file_title: str, is_stream: bool = False, headers: Optional[Dict[str, str]] = None) -> bool:
"""Play file in MPV using IPC pipe, creating new instance if needed.
"""Play file in MPV using centralized IPC pipe, creating new instance if needed.
Returns True on success, False on error.
"""
ipc_pipe = _get_fixed_ipc_pipe()
import json
import socket
import platform
try:
# First try to send to existing MPV instance
if _send_to_mpv_pipe(file_url, ipc_pipe, file_title, headers):
print(f"Added to MPV: {file_title}")
if send_to_mpv(file_url, file_title, headers):
debug(f"Added to MPV: {file_title}")
return True
# No existing MPV or pipe unavailable - start new instance
log(f"[get-file] Starting new MPV instance (pipe: {ipc_pipe})", file=sys.stderr)
cmd = ['mpv', file_url, f'--input-ipc-server={ipc_pipe}']
ipc_pipe = get_ipc_pipe_path()
debug(f"[get-file] Starting new MPV instance (pipe: {ipc_pipe})", file=sys.stderr)
# Set title for new instance
cmd.append(f'--force-media-title={file_title}')
# Build command - start MPV without a file initially, just with IPC server
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}']
if headers:
# Format headers for command line
@@ -387,19 +274,39 @@ def _play_in_mpv(file_url: str, file_title: str, is_stream: bool = False, header
header_str = ",".join([f"{k}: {v}" for k, v in headers.items()])
cmd.append(f'--http-header-fields={header_str}')
# Add --idle flag so MPV stays running and waits for playlist commands
cmd.append('--idle')
# Detach process to prevent freezing parent CLI
kwargs = {}
if platform.system() == 'Windows':
# CREATE_NEW_CONSOLE might be better than CREATE_NO_WINDOW if MPV needs a window
# But usually MPV creates its own window.
# DETACHED_PROCESS (0x00000008) is also an option.
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
_subprocess.Popen(cmd, stdin=_subprocess.DEVNULL, stdout=_subprocess.DEVNULL, stderr=_subprocess.DEVNULL, **kwargs)
print(f"{'Streaming' if is_stream else 'Playing'} in MPV: {file_title}")
log(f"[get-file] Started MPV with {file_title} (IPC: {ipc_pipe})", file=sys.stderr)
return True
debug(f"[get-file] Started MPV instance (IPC: {ipc_pipe})", file=sys.stderr)
# Give MPV time to start and open IPC pipe
# Windows needs more time than Unix
wait_time = 1.0 if platform.system() == 'Windows' else 0.5
debug(f"[get-file] Waiting {wait_time}s for MPV to initialize IPC...", file=sys.stderr)
_time.sleep(wait_time)
# Try up to 3 times to send the file via IPC
for attempt in range(3):
debug(f"[get-file] Sending file via IPC (attempt {attempt + 1}/3)", file=sys.stderr)
if send_to_mpv(file_url, file_title, headers):
debug(f"{'Streaming' if is_stream else 'Playing'} in MPV: {file_title}")
debug(f"[get-file] Added to new MPV instance (IPC: {ipc_pipe})", file=sys.stderr)
return True
if attempt < 2:
# Wait before retrying
_time.sleep(0.3)
# IPC send failed after all retries
log("Error: Could not send file to MPV via IPC after startup", file=sys.stderr)
return False
except FileNotFoundError:
log("Error: MPV not found. Install mpv to play media files", file=sys.stderr)
@@ -447,16 +354,16 @@ def _handle_search_result(result: Any, args: Sequence[str], config: Dict[str, An
log("Error: No storage backend specified in result", file=sys.stderr)
return 1
log(f"[get-file] Retrieving file from storage: {storage_name}", file=sys.stderr)
debug(f"[get-file] Retrieving file from storage: {storage_name}", file=sys.stderr)
# Handle different storage backends
if storage_name.lower() == 'hydrus':
return _handle_hydrus_file(file_hash, file_title, config, args, mime_type=mime_type)
elif storage_name.lower() == 'local':
return _handle_local_file(file_path, file_title, args, file_hash=file_hash)
return _handle_local_file(file_path, file_title, config, args, file_hash=file_hash)
elif storage_name.lower() == 'download':
# Downloads are local files
return _handle_local_file(file_path, file_title, args, file_hash=file_hash)
return _handle_local_file(file_path, file_title, config, args, file_hash=file_hash)
elif storage_name.lower() == 'debrid':
# Extract magnet_id from result (search-file stores it in full_metadata or as custom attribute)
if not magnet_id:
@@ -516,7 +423,7 @@ def _handle_hydrus_file(file_hash: Optional[str], file_title: str, config: Dict[
if force_browser:
# User explicitly wants browser
ipc_pipe = _get_fixed_ipc_pipe()
ipc_pipe = get_ipc_pipe_path()
result_dict = create_pipe_object_result(
source='hydrus',
identifier=file_hash,
@@ -536,44 +443,30 @@ def _handle_hydrus_file(file_hash: Optional[str], file_title: str, config: Dict[
try:
import webbrowser
webbrowser.open(web_url)
log(f"[get-file] Opened in browser: {file_title}", file=sys.stderr)
debug(f"[get-file] Opened in browser: {file_title}", file=sys.stderr)
except Exception:
pass
return 0
elif force_mpv or (is_media and mpv_available):
# Auto-play in MPV for media files (if available), or user requested it
if _play_in_mpv(stream_url, file_title, is_stream=True, headers=headers):
# Emit result as PipeObject-compatible dict for pipelining
ipc_pipe = _get_fixed_ipc_pipe()
result_dict = create_pipe_object_result(
source='hydrus',
identifier=file_hash,
file_path=stream_url,
cmdlet_name='get-file',
title=file_title,
file_hash=file_hash,
extra={
'ipc': ipc_pipe,
'action_type': 'streaming',
'web_url': web_url,
'hydrus_url': hydrus_url,
'access_key': access_key
}
)
ctx.emit(result_dict)
# Show pipe menu instead of emitting result for display
# This allows immediate @N selection from the playlist
from . import pipe
pipe._run(None, [], config)
return 0
else:
# Fall back to browser
try:
import webbrowser
webbrowser.open(web_url)
log(f"[get-file] Opened in browser instead", file=sys.stderr)
debug(f"[get-file] Opened in browser instead", file=sys.stderr)
except Exception:
pass
return 0
else:
# Not media, open in browser
ipc_pipe = _get_fixed_ipc_pipe()
ipc_pipe = get_ipc_pipe_path()
result_dict = create_pipe_object_result(
source='hydrus',
identifier=file_hash,
@@ -593,7 +486,7 @@ def _handle_hydrus_file(file_hash: Optional[str], file_title: str, config: Dict[
try:
import webbrowser
webbrowser.open(web_url)
log(f"[get-file] Opened in browser: {file_title}", file=sys.stderr)
debug(f"[get-file] Opened in browser: {file_title}", file=sys.stderr)
except Exception:
pass
return 0
@@ -605,7 +498,7 @@ def _handle_hydrus_file(file_hash: Optional[str], file_title: str, config: Dict[
return 1
def _handle_local_file(file_path: Optional[str], file_title: str, args: Sequence[str], file_hash: Optional[str] = None) -> int:
def _handle_local_file(file_path: Optional[str], file_title: str, config: Dict[str, Any], args: Sequence[str], file_hash: Optional[str] = None) -> int:
"""Handle file from local storage - auto-play in MPV if media, otherwise open with default app."""
if not file_path:
log("Error: No file path provided", file=sys.stderr)
@@ -641,7 +534,7 @@ def _handle_local_file(file_path: Optional[str], file_title: str, args: Sequence
else: # Linux
sp.run(['xdg-open', file_path])
ctx.emit(f"Opened: {file_title}")
log(f"[get-file] Opened {file_title} with default app", file=sys.stderr)
debug(f"[get-file] Opened {file_title} with default app", file=sys.stderr)
return 0
except Exception as e:
log(f"Error opening file: {e}", file=sys.stderr)
@@ -649,21 +542,10 @@ def _handle_local_file(file_path: Optional[str], file_title: str, args: Sequence
elif force_mpv or (is_media and mpv_available):
# Auto-play in MPV for media files (if available), or user requested it
if _play_in_mpv(file_path, file_title, is_stream=False):
# Emit result as PipeObject-compatible dict for pipelining
ipc_pipe = _get_fixed_ipc_pipe()
result_dict = create_pipe_object_result(
source='local',
identifier=str(Path(file_path).stem) if file_path else 'unknown',
file_path=file_path,
cmdlet_name='get-file',
title=file_title,
file_hash=file_hash, # Include hash from search result if available
extra={
'ipc': ipc_pipe, # MPV IPC pipe for Lua script control
'action_type': 'playing' # Distinguish from other get-file actions
}
)
ctx.emit(result_dict)
# Show pipe menu instead of emitting result for display
# This allows immediate @N selection from the playlist
from . import pipe
pipe._run(None, [], config)
return 0
else:
# Fall back to default application
@@ -676,7 +558,7 @@ def _handle_local_file(file_path: Optional[str], file_title: str, args: Sequence
os.startfile(file_path)
else: # Linux
_subprocess.run(['xdg-open', file_path])
log(f"[get-file] Opened with default app instead", file=sys.stderr)
debug(f"[get-file] Opened with default app instead", file=sys.stderr)
except Exception:
pass
return 0
@@ -694,7 +576,7 @@ def _handle_local_file(file_path: Optional[str], file_title: str, args: Sequence
else: # Linux
sp.run(['xdg-open', file_path])
print(f"Opened: {file_title}")
log(f"[get-file] Opened {file_title} with default app", file=sys.stderr)
debug(f"[get-file] Opened {file_title} with default app", file=sys.stderr)
# Emit result for downstream processing
result_dict = create_pipe_object_result(
@@ -751,7 +633,7 @@ def _handle_debrid_file(magnet_id: int, magnet_title: str, config: Dict[str, Any
try:
client = AllDebridClient(api_key)
log(f"[get-file] Downloading magnet {magnet_id}: {magnet_title}", file=sys.stderr)
debug(f"[get-file] Downloading magnet {magnet_id}: {magnet_title}", file=sys.stderr)
# Fetch magnet files
try:
@@ -1218,7 +1100,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Normal file export (happens regardless of -metadata flag)
try:
from downlow_helpers.hydrus import hydrus_export as _hydrus_export
from helper.hydrus import hydrus_export as _hydrus_export
except Exception:
_hydrus_export = None # type: ignore
if _hydrus_export is None:

View File

@@ -49,6 +49,7 @@ class TagItem:
hash_hex: Optional[str] = None
source: str = "hydrus"
service_name: Optional[str] = None
file_path: Optional[str] = None
def __post_init__(self):
# Make ResultTable happy by adding standard fields
@@ -101,7 +102,9 @@ def _emit_tags_as_table(
hash_hex: Optional[str],
source: str = "hydrus",
service_name: Optional[str] = None,
config: Dict[str, Any] = None
config: Dict[str, Any] = None,
item_title: Optional[str] = None,
file_path: Optional[str] = None
) -> None:
"""Emit tags as TagItem objects and display via ResultTable.
@@ -111,7 +114,13 @@ def _emit_tags_as_table(
from result_table import ResultTable
# Create ResultTable with just tag column (no title)
table = ResultTable("Tags", max_columns=1)
table_title = "Tags"
if item_title:
table_title = f"Tags: {item_title}"
if hash_hex:
table_title += f" [{hash_hex[:8]}]"
table = ResultTable(table_title, max_columns=1)
table.set_source_command("get-tag", [])
# Create TagItem for each tag
@@ -123,6 +132,7 @@ def _emit_tags_as_table(
hash_hex=hash_hex,
source=source,
service_name=service_name,
file_path=file_path,
)
tag_items.append(tag_item)
table.add_result(tag_item)
@@ -1069,6 +1079,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Try Hydrus first (always prioritize if available and has hash)
use_hydrus = False
hydrus_meta = None # Cache the metadata from first fetch
client = None
if hash_hex and hydrus_available:
try:
client = hydrus.get_client(config)
@@ -1093,7 +1104,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
try:
# Use cached metadata from above, don't fetch again
service_name = hydrus.get_tag_service_name(config)
client = hydrus.get_client(config)
if client is None:
client = hydrus.get_client(config)
service_key = hydrus.get_tag_service_key(client, service_name)
current = _extract_my_tags_from_hydrus_meta(hydrus_meta, service_key, service_name)
source = "hydrus"
@@ -1148,10 +1160,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1
# Always output to ResultTable (pipeline mode only)
# Extract title for table header
item_title = get_field(result, "title", None) or get_field(result, "name", None) or get_field(result, "filename", None)
if source == "hydrus":
_emit_tags_as_table(current, hash_hex=hash_hex, source="hydrus", service_name=service_name, config=config)
_emit_tags_as_table(current, hash_hex=hash_hex, source="hydrus", service_name=service_name, config=config, item_title=item_title)
else:
_emit_tags_as_table(current, hash_hex=hash_hex, source="local", service_name=None, config=config)
_emit_tags_as_table(current, hash_hex=hash_hex, source="local", service_name=None, config=config, item_title=item_title, file_path=str(local_path) if local_path else None)
# If emit requested or store key provided, emit payload
if emit_mode:

14
cmdlets/output_json.py Normal file
View File

@@ -0,0 +1,14 @@
from typing import Any, Dict, Sequence
import json
from ._shared import Cmdlet
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Output the current pipeline result as JSON."""
print(json.dumps(result, indent=2, default=str))
return 0
CMDLET = Cmdlet(
name="output-json",
summary="Output the current pipeline result as JSON.",
usage="... | output-json",
)

View File

@@ -6,94 +6,126 @@ import socket
import re
import subprocess
from ._shared import Cmdlet, CmdletArg, parse_cmdlet_args
from helper.logger import log
from helper.logger import log, debug
from result_table import ResultTable
from .get_file import _get_fixed_ipc_pipe
from helper.mpv_ipc import get_ipc_pipe_path, MPVIPCClient
import pipeline as ctx
def _send_ipc_command(command: Dict[str, Any]) -> Optional[Any]:
from helper.local_library import LocalLibrarySearchOptimizer
from config import get_local_storage_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."""
ipc_pipe = _get_fixed_ipc_pipe()
request = json.dumps(command) + "\n"
try:
if platform.system() == 'Windows':
# Windows named pipe
# Opening in r+b mode to read response
try:
with open(ipc_pipe, 'r+b', buffering=0) as pipe:
pipe.write(request.encode('utf-8'))
pipe.flush()
ipc_pipe = get_ipc_pipe_path()
client = MPVIPCClient(socket_path=ipc_pipe)
# Read response
# We'll try to read a line. This might block if MPV is unresponsive.
response_line = pipe.readline()
if response_line:
return json.loads(response_line.decode('utf-8'))
except FileNotFoundError:
return None # MPV not running
except Exception as e:
log(f"Windows IPC Error: {e}", file=sys.stderr)
return None
else:
# Unix socket
af_unix = getattr(socket, 'AF_UNIX', None)
if af_unix is None:
log("Unix sockets not supported on this platform", file=sys.stderr)
return None
try:
sock = socket.socket(af_unix, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect(ipc_pipe)
sock.sendall(request.encode('utf-8'))
# Read response
response_data = b""
while True:
try:
chunk = sock.recv(4096)
if not chunk:
break
response_data += chunk
if b"\n" in chunk:
break
except socket.timeout:
break
sock.close()
if response_data:
# Parse lines, look for response to our request
lines = response_data.decode('utf-8').strip().split('\n')
for line in lines:
try:
resp = json.loads(line)
# If it has 'error' field, it's a response
if 'error' in resp:
return resp
except:
pass
except (FileNotFoundError, ConnectionRefusedError):
return None # MPV not running
except Exception as e:
log(f"Unix IPC Error: {e}", file=sys.stderr)
return None
if not client.connect():
return None # MPV not running
response = client.send_command(command)
client.disconnect()
return response
except Exception as e:
log(f"IPC Error: {e}", file=sys.stderr)
if not silent:
debug(f"IPC Error: {e}", file=sys.stderr)
return None
return None
def _get_playlist() -> List[Dict[str, Any]]:
"""Get the current playlist from MPV."""
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)
if resp and resp.get("error") == "success":
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 _queue_items(items: List[Any], clear_first: bool = False) -> None:
"""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
"""
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:
# Add to MPV playlist
# We use loadfile with append flag (or replace if clear_first is set)
# Use memory:// M3U hack to pass title to MPV
if title:
# 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
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)
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Manage and play items in the MPV playlist via IPC."""
@@ -104,6 +136,148 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
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")
# 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)
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
else:
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
if play_mode:
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)
if result:
@@ -114,48 +288,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
elif isinstance(result, dict):
items_to_add = [result]
added_count = 0
for i, item in enumerate(items_to_add):
# Extract URL/Path
target = None
title = None
_queue_items(items_to_add)
if isinstance(item, dict):
target = item.get("target") or item.get("url") or item.get("path")
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:
# Add to MPV playlist
# We use loadfile with append flag
# Configure 1080p limit for streams (bestvideo<=1080p + bestaudio)
options = {
"ytdl-format": "bestvideo[height<=?1080]+bestaudio/best[height<=?1080]"
}
if title:
options["force-media-title"] = title
cmd = {"command": ["loadfile", target, "append", options], "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_to_add[i:])
return 0
elif resp.get("error") == "success":
added_count += 1
if title:
log(f"Queued: {title}")
else:
log(f"Queued: {target}")
if added_count > 0:
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
@@ -163,8 +298,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Get playlist from MPV
items = _get_playlist()
if items is None:
debug("MPV is not running. Starting new instance...")
_start_mpv([])
return 0
if not items:
log("MPV playlist is empty or MPV is not running.")
debug("MPV playlist is empty.")
return 0
# If index is provided, perform action (Play or Clear)
@@ -174,86 +314,70 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
idx = int(index_arg) - 1
if idx < 0 or idx >= len(items):
log(f"Index {index_arg} out of range (1-{len(items)}).")
debug(f"Index {index_arg} out of range (1-{len(items)}).")
return 1
item = items[idx]
title = item.get("title") or item.get("filename") or "Unknown"
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":
log(f"Removed: {title}")
debug(f"Removed: {title}")
# Refresh items for listing
items = _get_playlist()
items = _get_playlist() or []
list_mode = True
index_arg = None
else:
log(f"Failed to remove item: {resp.get('error') if resp else 'No response'}")
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":
log(f"Playing: {title}")
# Ensure playback starts (unpause)
unpause_cmd = {"command": ["set_property", "pause", False], "request_id": 103}
_send_ipc_command(unpause_cmd)
debug(f"Playing: {title}")
return 0
else:
log(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
except ValueError:
log(f"Invalid index: {index_arg}")
debug(f"Invalid index: {index_arg}")
return 1
# List items (Default action or after clear)
if list_mode or index_arg is None:
if not items:
log("MPV playlist is empty.")
debug("MPV playlist is empty.")
return 0
table = ResultTable("MPV Playlist")
# 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 = item.get("title") or ""
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
# Extract actual URL
# Find the first line that looks like a URL and not a directive
lines = filename.splitlines()
for line in lines:
line = line.strip()
if line and not line.startswith('#') and not line.startswith('memory://'):
filename = line
break
except Exception:
pass
title = _extract_title_from_item(item)
# Truncate if too long
if len(title) > 57:
title = title[:57] + "..."
if len(filename) > 27:
filename = filename[:27] + "..."
if len(title) > 80:
title = title[:77] + "..."
row = table.add_row()
row.add_column("#", str(i + 1))
row.add_column("Current", "*" if is_current else "")
row.add_column("Title", title)
row.add_column("Filename", filename)
table.set_row_selection_args(i, [str(i + 1)])
@@ -269,20 +393,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
def _start_mpv(items: List[Any]) -> None:
"""Start MPV with a list of items."""
ipc_pipe = _get_fixed_ipc_pipe()
ipc_pipe = get_ipc_pipe_path()
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}']
cmd = ['mpv', f'--input-ipc-server={ipc_pipe}', '--idle', '--force-window']
cmd.append('--ytdl-format=bestvideo[height<=?1080]+bestaudio/best[height<=?1080]')
# Add items
first_title_set = False
for item in items:
target = None
title = None
if isinstance(item, dict):
target = item.get("target") or item.get("url") or item.get("path")
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
@@ -291,21 +413,23 @@ def _start_mpv(items: List[Any]) -> None:
target = item
if target:
if not first_title_set and title:
cmd.append(f'--force-media-title={title}')
first_title_set = True
cmd.append(target)
if title:
# Use memory:// M3U hack to pass title
safe_title = title.replace('\n', ' ').replace('\r', '')
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
cmd.append(f"memory://{m3u_content}")
else:
cmd.append(target)
if len(cmd) > 3: # mpv + ipc + format + at least one file
try:
kwargs = {}
if platform.system() == 'Windows':
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
try:
kwargs = {}
if platform.system() == 'Windows':
kwargs['creationflags'] = 0x00000008 # DETACHED_PROCESS
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
log(f"Started MPV with {len(cmd)-3} items")
except Exception as e:
log(f"Error starting MPV: {e}", file=sys.stderr)
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
debug(f"Started MPV with {len(items)} items")
except Exception as e:
debug(f"Error starting MPV: {e}", file=sys.stderr)
CMDLET = Cmdlet(
name=".pipe",
@@ -322,13 +446,33 @@ CMDLET = Cmdlet(
CmdletArg(
name="clear",
type="flag",
description="Remove the selected item from the playlist"
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"
),
],
exec=_run
)

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import contextlib
import hashlib
import importlib
import json
import sys
import time
import httpx
@@ -17,8 +18,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from urllib.parse import urlsplit, quote, urljoin
from helper.logger import log
from helper.logger import log, debug
from helper.http_client import HTTPClient
from helper.utils import ensure_directory, unique_path, unique_preserve_order
from . import register
from ._shared import Cmdlet, CmdletArg, SharedArgs, create_pipe_object_result, normalize_result_input
@@ -70,6 +72,38 @@ USER_AGENT = (
DEFAULT_VIEWPORT: ViewportSize = {"width": 1280, "height": 1200}
ARCHIVE_TIMEOUT = 30.0
# Configurable selectors for specific websites
SITE_SELECTORS: Dict[str, List[str]] = {
"twitter.com": [
"article[role='article']",
"div[data-testid='tweet']",
"div[data-testid='cellInnerDiv'] article",
],
"x.com": [
"article[role='article']",
"div[data-testid='tweet']",
"div[data-testid='cellInnerDiv'] article",
],
"instagram.com": [
"article[role='presentation']",
"article[role='article']",
"div[role='dialog'] article",
"section main article",
],
"reddit.com": [
"shreddit-post",
"div[data-testid='post-container']",
"div[data-click-id='background']",
"article",
],
"rumble.com": [
"rumble-player, iframe.rumble",
"div.video-item--main",
"main article",
],
}
class ScreenshotError(RuntimeError):
"""Raised when screenshot capture or upload fails."""
@@ -113,39 +147,6 @@ class ScreenshotResult:
# Helper Functions
# ============================================================================
def _ensure_directory(path: Path) -> None:
"""Ensure directory exists."""
if not isinstance(path, Path):
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
def _unique_path(path: Path) -> Path:
"""Get unique path by appending numbers if file exists."""
if not path.exists():
return path
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new_path = parent / f"{stem}_{counter}{suffix}"
if not new_path.exists():
return new_path
counter += 1
def _unique_preserve_order(items: Sequence[str]) -> List[str]:
"""Remove duplicates while preserving order."""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
def _slugify_url(url: str) -> str:
"""Convert URL to filesystem-safe slug."""
parsed = urlsplit(url)
@@ -180,36 +181,11 @@ def _selectors_for_url(url: str) -> List[str]:
"""Return a list of likely content selectors for known platforms."""
u = url.lower()
sels: List[str] = []
# Twitter/X
if "twitter.com" in u or "x.com" in u:
sels.extend([
"article[role='article']",
"div[data-testid='tweet']",
"div[data-testid='cellInnerDiv'] article",
])
# Instagram
if "instagram.com" in u:
sels.extend([
"article[role='presentation']",
"article[role='article']",
"div[role='dialog'] article",
"section main article",
])
# Reddit
if "reddit.com" in u:
sels.extend([
"shreddit-post",
"div[data-testid='post-container']",
"div[data-click-id='background']",
"article",
])
# Rumble (video post)
if "rumble.com" in u:
sels.extend([
"rumble-player, iframe.rumble",
"div.video-item--main",
"main article",
])
for domain, selectors in SITE_SELECTORS.items():
if domain in u:
sels.extend(selectors)
return sels or ["article"]
@@ -321,7 +297,7 @@ def _archive_url(url: str, timeout: float) -> Tuple[List[str], List[str]]:
def _prepare_output_path(options: ScreenshotOptions) -> Path:
"""Prepare and validate output path for screenshot."""
_ensure_directory(options.output_dir)
ensure_directory(options.output_dir)
explicit_format = _normalise_format(options.output_format) if options.output_format else None
inferred_format: Optional[str] = None
if options.output_path is not None:
@@ -344,20 +320,23 @@ def _prepare_output_path(options: ScreenshotOptions) -> Path:
if current_suffix != expected:
path = path.with_suffix(expected)
options.output_format = final_format
return _unique_path(path)
return unique_path(path)
def _capture_with_playwright(options: ScreenshotOptions, destination: Path, warnings: List[str]) -> None:
def _capture(options: ScreenshotOptions, destination: Path, warnings: List[str]) -> None:
"""Capture screenshot using Playwright."""
debug(f"[_capture] Starting capture for {options.url} -> {destination}")
playwright = None
browser = None
context = None
try:
log("Starting Playwright...", flush=True)
debug("Starting Playwright...", flush=True)
playwright = sync_playwright().start()
log("Launching Chromium browser...", flush=True)
format_name = _normalise_format(options.output_format)
headless = options.headless or format_name == "pdf"
debug(f"[_capture] Format: {format_name}, Headless: {headless}")
if format_name == "pdf" and not options.headless:
warnings.append("pdf output requires headless Chromium; overriding headless mode")
browser = playwright.chromium.launch(
@@ -413,11 +392,14 @@ def _capture_with_playwright(options: ScreenshotOptions, destination: Path, warn
log("Attempting platform-specific content capture...", flush=True)
try:
_platform_preprocess(options.url, page, warnings)
except Exception:
except Exception as e:
debug(f"[_capture] Platform preprocess failed: {e}")
pass
selectors = list(options.target_selectors or [])
if not selectors:
selectors = _selectors_for_url(options.url)
debug(f"[_capture] Trying selectors: {selectors}")
for sel in selectors:
try:
log(f"Trying selector: {sel}", flush=True)
@@ -466,6 +448,7 @@ def _capture_with_playwright(options: ScreenshotOptions, destination: Path, warn
page.screenshot(**screenshot_kwargs)
log(f"Screenshot saved to {destination}", flush=True)
except Exception as exc:
debug(f"[_capture] Exception: {exc}")
raise ScreenshotError(f"Failed to capture screenshot: {exc}") from exc
finally:
log("Cleaning up browser resources...", flush=True)
@@ -483,20 +466,22 @@ def _capture_with_playwright(options: ScreenshotOptions, destination: Path, warn
def _capture_screenshot(options: ScreenshotOptions) -> ScreenshotResult:
"""Capture a screenshot for the given options."""
debug(f"[_capture_screenshot] Preparing capture for {options.url}")
destination = _prepare_output_path(options)
warnings: List[str] = []
_capture_with_playwright(options, destination, warnings)
_capture(options, destination, warnings)
known_urls = _unique_preserve_order([options.url, *options.known_urls])
known_urls = unique_preserve_order([options.url, *options.known_urls])
archive_urls: List[str] = []
if options.archive:
debug(f"[_capture_screenshot] Archiving enabled for {options.url}")
archives, archive_warnings = _archive_url(options.url, options.archive_timeout)
archive_urls.extend(archives)
warnings.extend(archive_warnings)
if archives:
known_urls = _unique_preserve_order([*known_urls, *archives])
known_urls = unique_preserve_order([*known_urls, *archives])
applied_tags = _unique_preserve_order(list(tag for tag in options.tags if tag.strip()))
applied_tags = unique_preserve_order(list(tag for tag in options.tags if tag.strip()))
return ScreenshotResult(
path=destination,
@@ -530,6 +515,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""
from ._shared import parse_cmdlet_args
debug(f"[_run] screen-shot invoked with args: {args}")
# Help check
try:
if any(str(a).lower() in {"-?", "/?", "--help", "-h", "help", "--cmdlet"} for a in args):
@@ -581,6 +568,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log(f"No URLs to process for screen-shot cmdlet", file=sys.stderr)
return 1
debug(f"[_run] URLs to process: {urls_to_process}")
# ========================================================================
# OUTPUT DIRECTORY RESOLUTION - Priority chain
# ========================================================================
@@ -617,7 +606,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
screenshot_dir = Path.home() / "Videos"
log(f"[screen_shot] Using default directory: {screenshot_dir}", flush=True)
_ensure_directory(screenshot_dir)
ensure_directory(screenshot_dir)
# ========================================================================
# PREPARE SCREENSHOT OPTIONS

View File

@@ -156,7 +156,7 @@ CMDLET = Cmdlet(
CmdletArg("size", description="Filter by size: >100MB, <50MB, =10MB"),
CmdletArg("type", description="Filter by type: audio, video, image, document"),
CmdletArg("duration", description="Filter by duration: >10:00, <1:30:00"),
CmdletArg("limit", type="integer", description="Limit results (default: 100)"),
CmdletArg("limit", type="integer", description="Limit results (default: 45)"),
CmdletArg("storage", description="Search storage backend: hydrus, local, debrid (default: all searchable)"),
CmdletArg("provider", description="Search provider: libgen, openlibrary, soulseek, debrid, local (overrides -storage)"),
],
@@ -190,7 +190,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
type_filter: Optional[str] = None
storage_backend: Optional[str] = None
provider_name: Optional[str] = None
limit = 100
limit = 45
# Simple argument parsing
i = 0
@@ -216,8 +216,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
elif low in {"-type", "--type"} and i + 1 < len(args_list):
type_filter = args_list[i + 1].lower()
i += 2
elif not query and not arg.startswith("-"):
query = arg
elif not arg.startswith("-"):
if query:
query += " " + arg
else:
query = arg
i += 1
else:
i += 1
@@ -246,6 +249,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
try:
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:
@@ -261,10 +278,17 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
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
@@ -313,6 +337,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Emit results and collect for workers table
if results:
for item in results:
# Add to table
table.add_result(item)
if isinstance(item, dict):
normalized = _ensure_storage_columns(item)
results_list.append(normalized)
@@ -326,6 +353,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
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)
# Write results to worker stdout
db.append_worker_stdout(worker_id, json.dumps(results_list, indent=2))
else:

View File

@@ -1,32 +0,0 @@
{
"debug": false,
"provider": {
"openlibrary": {
"email": "e@.com",
"password": "pass"
},
"soulseek": {
"password": "whateveryouwant",
"username": "pickrandomcantbeinuse"
}
},
"storage": {
"debrid": {
"All-debrid": "put in your api key for debrid here"
},
"hydrus": {
"home": {
"key": "hydrus-access-key-goes-here",
"url": "http://192.168.1.###:45869"
},
"work": {
"key": null,
"url": null
}
},
"local": {
"path": "C:\\your\\local\\database\\folder"
}
},
"temp": "C:\\do\\not\\use\\system\\temp"
}

View File

@@ -54,8 +54,15 @@ def _progress_callback(status: Dict[str, Any]) -> None:
if event == "downloading":
percent = status.get("_percent_str", "?")
speed = status.get("_speed_str", "?")
debug(f"Downloading {percent} at {speed}")
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.flush()
elif event == "finished":
# Clear the progress line
sys.stdout.write("\r" + " " * 70 + "\r")
sys.stdout.flush()
# Log finished message (visible)
debug(f"✓ Download finished: {status.get('filename')}")
elif event in ("postprocessing", "processing"):
debug(f"Post-processing: {status.get('postprocessor')}")
@@ -100,8 +107,8 @@ def list_formats(url: str, no_playlist: bool = False, playlist_items: Optional[s
try:
ydl_opts = {
"quiet": False,
"no_warnings": False,
"quiet": True,
"no_warnings": True,
"socket_timeout": 30,
}
@@ -154,9 +161,9 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]:
base_options: Dict[str, Any] = {
"outtmpl": outtmpl,
"quiet": False,
"no_warnings": False,
"noprogress": False,
"quiet": True,
"no_warnings": True,
"noprogress": True,
"socket_timeout": 30,
"retries": 10,
"fragment_retries": 10,
@@ -622,7 +629,7 @@ def download_media(
_ensure_yt_dlp_ready()
ytdl_options = _build_ytdlp_options(opts)
log(f"Starting yt-dlp download: {opts.url}")
debug(f"Starting yt-dlp download: {opts.url}")
if debug_logger is not None:
debug_logger.write_record("ytdlp-start", {"url": opts.url})
@@ -700,7 +707,7 @@ def download_media(
or entry.get("url")
)
log(f"✓ Downloaded: {media_path.name} ({len(tags)} tags)")
debug(f"✓ Downloaded: {media_path.name} ({len(tags)} tags)")
if debug_logger is not None:
debug_logger.write_record(
"downloaded",

View File

@@ -24,6 +24,7 @@ from typing import Any, Dict, Optional
import sys
import shutil
import requests
import re
from helper.logger import log, debug
@@ -49,6 +50,10 @@ class StorageBackend(ABC):
Exception: If upload fails
"""
@abstractmethod
def get_name(self) -> str:
"""Get the unique name of this backend."""
def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Search for files in backends that support it.
@@ -124,7 +129,7 @@ class LocalStorageBackend(StorageBackend):
try:
# Compute file hash
file_hash = sha256_file(file_path)
log(f"File hash: {file_hash}", file=sys.stderr)
debug(f"File hash: {file_hash}", file=sys.stderr)
dest_dir = Path(location).expanduser()
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -147,13 +152,13 @@ class LocalStorageBackend(StorageBackend):
if move_file:
shutil.move(str(file_path), dest_file)
log(f"Local move: {dest_file}", file=sys.stderr)
debug(f"Local move: {dest_file}", file=sys.stderr)
else:
shutil.copy2(file_path, dest_file)
log(f"Local copy: {dest_file}", file=sys.stderr)
debug(f"Local copy: {dest_file}", file=sys.stderr)
return str(dest_file)
except Exception as exc:
log(f"Local copy failed: {exc}", file=sys.stderr)
debug(f"Local copy failed: {exc}", file=sys.stderr)
raise
def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
@@ -199,7 +204,6 @@ class LocalStorageBackend(StorageBackend):
# Try database search first (much faster than filesystem scan)
try:
debug(f"Connecting to local library DB at {search_dir}")
db = LocalLibraryDB(search_dir)
cursor = db.connection.cursor()
@@ -260,8 +264,9 @@ class LocalStorageBackend(StorageBackend):
all_tags = [row[0] for row in cursor.fetchall()]
results.append({
"name": file_path.name,
"title": file_path.name,
"name": file_path.stem,
"title": file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
@@ -283,23 +288,64 @@ class LocalStorageBackend(StorageBackend):
# 2. Simple tags (without namespace) containing the query
# NOTE: Does NOT match namespaced tags (e.g., "joe" won't match "channel:Joe Mullan")
# Use explicit namespace search for that (e.g., "channel:joe*")
query_pattern = f"%{query_lower}%"
debug(f"Performing filename/tag search: {query_pattern}")
cursor.execute("""
# Split query into terms for AND logic
terms = [t.strip() for t in query_lower.replace(',', ' ').split() if t.strip()]
if not terms:
terms = [query_lower]
debug(f"Performing filename/tag search for terms: {terms}")
# Fetch more results than requested to allow for filtering
fetch_limit = (limit or 45) * 50
# 1. Filename search (AND logic)
conditions = ["LOWER(f.file_path) LIKE ?" for _ in terms]
params = [f"%{t}%" for t in terms]
where_clause = " AND ".join(conditions)
cursor.execute(f"""
SELECT DISTINCT f.id, f.file_path, f.file_size
FROM files f
WHERE LOWER(f.file_path) LIKE ?
WHERE {where_clause}
ORDER BY f.file_path
LIMIT ?
""", (query_pattern, limit or 1000))
""", (*params, fetch_limit))
rows = cursor.fetchall()
debug(f"Found {len(rows)} filename matches in DB")
debug(f"Found {len(rows)} filename matches in DB (before whole-word filter)")
# Compile regex for whole word matching (only if single term, otherwise skip)
word_regex = None
if len(terms) == 1:
term = terms[0]
# Check if term contains wildcard characters
has_wildcard = '*' in term or '?' in term
if has_wildcard:
# Use fnmatch for wildcard patterns (e.g., "sie*" matches "SiebeliebenWohl...")
try:
from fnmatch import translate
word_regex = re.compile(translate(term), re.IGNORECASE)
except Exception:
word_regex = None
else:
# Use word boundary for exact terms (backwards compatibility)
try:
word_regex = re.compile(r'\b' + re.escape(term) + r'\b', re.IGNORECASE)
except Exception:
word_regex = None
seen_files = set()
for file_id, file_path_str, size_bytes in rows:
if not file_path_str or file_path_str in seen_files:
continue
# Apply whole word filter on filename if single term
if word_regex:
p = Path(file_path_str)
if not word_regex.search(p.name):
continue
seen_files.add(file_path_str)
file_path = Path(file_path_str)
@@ -315,8 +361,9 @@ class LocalStorageBackend(StorageBackend):
tags = [row[0] for row in cursor.fetchall()]
results.append({
"name": file_path.name,
"title": file_path.name,
"name": file_path.stem,
"title": file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
@@ -326,6 +373,12 @@ class LocalStorageBackend(StorageBackend):
})
# Also search for simple tags (without namespace) containing the query
# Only perform tag search if single term, or if we want to support multi-term tag search
# For now, fallback to single pattern search for tags if multiple terms
# (searching for a tag that contains "term1 term2" or "term1,term2")
# This is less useful for AND logic across multiple tags, but consistent with previous behavior
query_pattern = f"%{query_lower}%"
cursor.execute("""
SELECT DISTINCT f.id, f.file_path, f.file_size
FROM files f
@@ -354,8 +407,9 @@ class LocalStorageBackend(StorageBackend):
tags = [row[0] for row in cursor.fetchall()]
results.append({
"name": file_path.name,
"title": file_path.name,
"name": file_path.stem,
"title": file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
@@ -392,8 +446,9 @@ class LocalStorageBackend(StorageBackend):
tags = [row[0] for row in cursor.fetchall()]
results.append({
"name": file_path.name,
"title": file_path.name,
"name": file_path.stem,
"title": file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
@@ -417,6 +472,11 @@ class LocalStorageBackend(StorageBackend):
recursive = kwargs.get("recursive", True)
pattern = "**/*" if recursive else "*"
# Split query into terms for AND logic
terms = [t.strip() for t in query_lower.replace(',', ' ').split() if t.strip()]
if not terms:
terms = [query_lower]
count = 0
for file_path in search_dir.glob(pattern):
if not file_path.is_file():
@@ -425,14 +485,26 @@ class LocalStorageBackend(StorageBackend):
if lower_name.endswith('.tags') or lower_name.endswith('.metadata') \
or lower_name.endswith('.notes') or lower_name.endswith('.tags.txt'):
continue
if not (match_all or query_lower in lower_name):
continue
if not match_all:
# Check if ALL terms are present in the filename
# For single terms with wildcards, use fnmatch; otherwise use substring matching
if len(terms) == 1 and ('*' in terms[0] or '?' in terms[0]):
# Wildcard pattern matching for single term
from fnmatch import fnmatch
if not fnmatch(lower_name, terms[0]):
continue
else:
# Substring matching for all terms (AND logic)
if not all(term in lower_name for term in terms):
continue
size_bytes = file_path.stat().st_size
path_str = str(file_path)
results.append({
"name": file_path.name,
"title": file_path.name,
"name": file_path.stem,
"title": file_path.stem,
"ext": file_path.suffix.lstrip('.'),
"path": path_str,
"target": path_str,
"origin": "local",
@@ -545,7 +617,7 @@ class HydrusStorageBackend(StorageBackend):
raise Exception(f"Hydrus response missing file hash: {response}")
file_hash = hydrus_hash
log(f"✅ File uploaded to Hydrus: {file_hash}", file=sys.stderr)
log(f"Hydrus: {file_hash}", file=sys.stderr)
# Add tags if provided
if tags:
@@ -637,7 +709,8 @@ class HydrusStorageBackend(StorageBackend):
# Fetch metadata for the found files
results = []
query_lower = query.lower().strip()
search_terms = set(query_lower.split()) # For substring matching
# Split by comma or space for AND logic
search_terms = set(query_lower.replace(',', ' ').split()) # For substring matching
if file_ids:
metadata = client.fetch_file_metadata(file_ids=file_ids)
@@ -706,8 +779,23 @@ class HydrusStorageBackend(StorageBackend):
})
else:
# Free-form search: check if search terms match the title or tags
# Match if ANY search term is found in title or tags (OR logic)
if query_lower == "*" or any(term in all_tags_str or term in title.lower() for term in search_terms):
# Match if ALL search terms are found in title or tags (AND logic)
# AND use whole word matching
# Combine title and tags for searching
searchable_text = (title + " " + all_tags_str).lower()
match = True
if query_lower != "*":
for term in search_terms:
# Regex for whole word: \bterm\b
# Escape term to handle special chars
pattern = r'\b' + re.escape(term) + r'\b'
if not re.search(pattern, searchable_text):
match = False
break
if match:
results.append({
"hash": hash_hex,
"hash_hex": hash_hex,
@@ -820,6 +908,11 @@ class DebridStorageBackend(StorageBackend):
# "*" means "match all" - include all magnets
match_all = query_lower == "*"
# Split query into terms for AND logic
terms = [t.strip() for t in query_lower.replace(',', ' ').split() if t.strip()]
if not terms:
terms = [query_lower]
for magnet in magnets:
filename = magnet.get('filename', '').lower()
status_code = magnet.get('statusCode', 0)
@@ -830,8 +923,9 @@ class DebridStorageBackend(StorageBackend):
continue
# Match query against filename (or match all if query is "*")
if not match_all and query_lower not in filename:
continue
if not match_all:
if not all(term in filename for term in terms):
continue
matching_magnet_ids.append(magnet_id)
magnet_info_map[magnet_id] = magnet
@@ -920,6 +1014,102 @@ class DebridStorageBackend(StorageBackend):
return result
class MatrixStorageBackend(StorageBackend):
"""File storage backend for Matrix (Element) chat rooms."""
def get_name(self) -> str:
return "matrix"
def upload(self, file_path: Path, **kwargs: Any) -> str:
"""Upload file to Matrix room.
Requires 'config' in kwargs with 'storage.matrix' settings:
- homeserver: URL of homeserver (e.g. https://matrix.org)
- user_id: User ID (e.g. @user:matrix.org)
- access_token: Access token (preferred) OR password
- room_id: Room ID to upload to (e.g. !roomid:matrix.org)
"""
config = kwargs.get('config', {})
if not config:
raise ValueError("Config required for Matrix upload")
matrix_conf = config.get('storage', {}).get('matrix', {})
if not matrix_conf:
raise ValueError("Matrix storage not configured in config.json")
homeserver = matrix_conf.get('homeserver')
# user_id = matrix_conf.get('user_id') # Not strictly needed if we have token
access_token = matrix_conf.get('access_token')
room_id = matrix_conf.get('room_id')
if not homeserver or not room_id:
raise ValueError("Matrix homeserver and room_id required")
# Ensure homeserver has protocol
if not homeserver.startswith('http'):
homeserver = f"https://{homeserver}"
# Login if no access token (optional implementation, for now assume token)
if not access_token:
raise ValueError("Matrix access_token required (login not yet implemented)")
# 1. Upload Media
upload_url = f"{homeserver}/_matrix/media/r3/upload"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream" # Or guess mime type
}
import mimetypes
mime_type, _ = mimetypes.guess_type(file_path)
if mime_type:
headers["Content-Type"] = mime_type
filename = file_path.name
try:
with open(file_path, 'rb') as f:
resp = requests.post(upload_url, headers=headers, data=f, params={"filename": filename})
if resp.status_code != 200:
raise Exception(f"Matrix upload failed: {resp.text}")
content_uri = resp.json().get('content_uri')
if not content_uri:
raise Exception("No content_uri returned from Matrix upload")
# 2. Send Message
send_url = f"{homeserver}/_matrix/client/r0/rooms/{room_id}/send/m.room.message"
# Determine msgtype
msgtype = "m.file"
if mime_type:
if mime_type.startswith("image/"): msgtype = "m.image"
elif mime_type.startswith("video/"): msgtype = "m.video"
elif mime_type.startswith("audio/"): msgtype = "m.audio"
payload = {
"msgtype": msgtype,
"body": filename,
"url": content_uri,
"info": {
"mimetype": mime_type,
"size": file_path.stat().st_size
}
}
resp = requests.post(send_url, headers=headers, json=payload)
if resp.status_code != 200:
raise Exception(f"Matrix send message failed: {resp.text}")
event_id = resp.json().get('event_id')
return f"matrix://{room_id}/{event_id}"
except Exception as e:
log(f"❌ Matrix upload error: {e}", file=sys.stderr)
raise
class FileStorage:
"""Unified file storage interface supporting multiple backend services.
@@ -966,6 +1156,9 @@ class FileStorage:
if debrid_api_key:
self._backends["debrid"] = DebridStorageBackend(api_key=debrid_api_key)
# Include Matrix backend
self._backends["matrix"] = MatrixStorageBackend()
def __getitem__(self, backend_name: str) -> StorageBackend:
"""Get a storage backend by name.

View File

@@ -1411,7 +1411,7 @@ def get_client(config: dict[str, Any]) -> HydrusClient:
cached_client = _hydrus_client_cache[cache_key]
# If cached client has a session key, reuse it (don't re-acquire)
if hasattr(cached_client, '_session_key') and cached_client._session_key:
debug(f"Reusing cached session key for {hydrus_url}")
# debug(f"Reusing cached session key for {hydrus_url}")
return cached_client
# If no session key in cache, try to get one
try:

View File

@@ -231,6 +231,16 @@ class LocalLibraryDB:
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
items TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Worker tracking tables (drop legacy workers table if still present)
self._ensure_worker_tables(cursor)
@@ -1386,6 +1396,104 @@ class LocalLibrarySearchOptimizer:
"""Fast tag-based search using database."""
if not self.db:
return []
try:
cursor = self.db.connection.cursor()
cursor.execute("""
SELECT f.file_path
FROM files f
JOIN tags t ON f.id = t.file_id
WHERE t.tag LIKE ?
LIMIT ?
""", (f"%{tag}%", limit))
return [Path(row[0]) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Tag search failed: {e}")
return []
def save_playlist(self, name: str, items: List[Dict[str, Any]]) -> bool:
"""Save a playlist to the database."""
if not self.db:
return False
try:
cursor = self.db.connection.cursor()
items_json = json.dumps(items)
cursor.execute("""
INSERT INTO playlists (name, items, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
items = excluded.items,
updated_at = CURRENT_TIMESTAMP
""", (name, items_json))
self.db.connection.commit()
return True
except Exception as e:
logger.error(f"Failed to save playlist {name}: {e}")
return False
def get_playlists(self) -> List[Dict[str, Any]]:
"""Get all saved playlists."""
if not self.db:
return []
try:
cursor = self.db.connection.cursor()
cursor.execute("SELECT id, name, items, updated_at FROM playlists ORDER BY updated_at DESC")
results = []
for row in cursor.fetchall():
try:
items = json.loads(row['items'])
except json.JSONDecodeError:
items = []
results.append({
'id': row['id'],
'name': row['name'],
'items': items,
'updated_at': row['updated_at']
})
return results
except Exception as e:
logger.error(f"Failed to get playlists: {e}")
return []
def get_playlist(self, name: str) -> Optional[List[Dict[str, Any]]]:
"""Get a specific playlist by name."""
if not self.db:
return None
try:
cursor = self.db.connection.cursor()
cursor.execute("SELECT items FROM playlists WHERE name = ?", (name,))
row = cursor.fetchone()
if row:
try:
return json.loads(row['items'])
except json.JSONDecodeError:
return []
return None
except Exception as e:
logger.error(f"Failed to get playlist {name}: {e}")
return None
def get_playlist_by_id(self, playlist_id: int) -> Optional[Tuple[str, List[Dict[str, Any]]]]:
"""Get a specific playlist by ID. Returns (name, items)."""
if not self.db:
return None
try:
cursor = self.db.connection.cursor()
cursor.execute("SELECT name, items FROM playlists WHERE id = ?", (playlist_id,))
row = cursor.fetchone()
if row:
try:
items = json.loads(row['items'])
return (row['name'], items)
except json.JSONDecodeError:
return (row['name'], [])
return None
except Exception as e:
logger.error(f"Failed to get playlist ID {playlist_id}: {e}")
return None
if not self.db:
return []
return self.db.search_by_tag(tag, limit)
def search_by_hash(self, file_hash: str) -> Optional[Path]:

290
helper/mpv_ipc.py Normal file
View File

@@ -0,0 +1,290 @@
"""MPV IPC client for cross-platform communication.
This module provides a cross-platform interface to communicate with mpv
using either named pipes (Windows) or Unix domain sockets (Linux/macOS).
This is the central hub for all Python-mpv IPC communication. The Lua script
should use the Python CLI, which uses this module to manage mpv connections.
"""
import json
import os
import platform
import socket
import time as _time
from typing import Any, Dict, Optional, List
from helper.logger import debug
# Fixed pipe name for persistent MPV connection across all Python sessions
FIXED_IPC_PIPE_NAME = "mpv-medeia-macina"
class MPVIPCError(Exception):
"""Raised when MPV IPC communication fails."""
pass
def get_ipc_pipe_path() -> str:
"""Get the fixed IPC pipe/socket path for persistent MPV connection.
Uses a fixed name so all playback sessions connect to the same MPV
window/process instead of creating new instances.
Returns:
Path to IPC pipe (Windows) or socket (Linux/macOS)
"""
system = platform.system()
if system == "Windows":
return f"\\\\.\\pipe\\{FIXED_IPC_PIPE_NAME}"
elif system == "Darwin": # macOS
return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock"
else: # Linux and others
return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock"
class MPVIPCClient:
"""Client for communicating with mpv via IPC socket/pipe.
This is the unified interface for all Python code to communicate with mpv.
It handles platform-specific differences (Windows named pipes vs Unix sockets).
"""
def __init__(self, socket_path: Optional[str] = None, timeout: float = 5.0):
"""Initialize MPV IPC client.
Args:
socket_path: Path to IPC socket/pipe. If None, uses the fixed persistent path.
timeout: Socket timeout in seconds.
"""
self.timeout = timeout
self.socket_path = socket_path or get_ipc_pipe_path()
self.sock = None
self.is_windows = platform.system() == "Windows"
def connect(self) -> bool:
"""Connect to mpv IPC socket.
Returns:
True if connection successful, False otherwise.
"""
try:
if self.is_windows:
# Windows named pipes
try:
# Try to open the named pipe
self.sock = open(self.socket_path, 'r+b', buffering=0)
return True
except (OSError, IOError) as exc:
debug(f"Failed to connect to MPV named pipe: {exc}")
return False
else:
# Unix domain socket (Linux, macOS)
if not os.path.exists(self.socket_path):
debug(f"IPC socket not found: {self.socket_path}")
return False
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(self.socket_path)
return True
except Exception as exc:
debug(f"Failed to connect to MPV IPC: {exc}")
self.sock = None
return False
def send_command(self, command_data: Dict[str, Any] | List[Any]) -> Optional[Dict[str, Any]]:
"""Send a command to mpv and get response.
Args:
command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...])
Returns:
Response dict with 'error' key (value 'success' on success), or None on error.
"""
if not self.sock:
if not self.connect():
return None
try:
# Format command as JSON (mpv IPC protocol)
if isinstance(command_data, list):
request = {"command": command_data}
else:
request = command_data
# Add request_id if not present to match response
if "request_id" not in request:
request["request_id"] = int(_time.time() * 1000) % 100000
payload = json.dumps(request) + "\n"
# Send command
if self.is_windows:
self.sock.write(payload.encode('utf-8'))
self.sock.flush()
else:
self.sock.sendall(payload.encode('utf-8'))
# Receive response
# We need to read lines until we find the one with matching request_id
# or until timeout/error. MPV might send events in between.
start_time = _time.time()
while _time.time() - start_time < self.timeout:
response_data = b""
if self.is_windows:
try:
response_data = self.sock.readline()
except (OSError, IOError):
return None
else:
try:
# This is simplistic for Unix socket (might not get full line)
# But for now assuming MPV sends line-buffered JSON
chunk = self.sock.recv(4096)
if not chunk:
break
response_data = chunk
# TODO: Handle partial lines if needed
except socket.timeout:
return None
if not response_data:
break
try:
lines = response_data.decode('utf-8').strip().split('\n')
for line in lines:
if not line: continue
resp = json.loads(line)
# Check if this is the response to our request
if resp.get("request_id") == request.get("request_id"):
return resp
# If it's an error without request_id (shouldn't happen for commands)
if "error" in resp and "request_id" not in resp:
# Might be an event or async error
pass
except json.JSONDecodeError:
pass
return None
except Exception as exc:
debug(f"Error sending command to MPV: {exc}")
self.disconnect()
return None
def disconnect(self) -> None:
"""Disconnect from mpv IPC socket."""
if self.sock:
try:
self.sock.close()
except Exception:
pass
self.sock = None
def __del__(self) -> None:
"""Cleanup on object destruction."""
self.disconnect()
def __enter__(self):
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.disconnect()
def send_to_mpv(file_url: str, title: str, headers: Optional[Dict[str, str]] = None,
append: bool = True) -> bool:
"""Send a file to be played in the existing MPV instance via IPC.
This attempts to send to an existing MPV instance. If it fails, the calling
code should start a new MPV instance with the IPC pipe.
Args:
file_url: URL or path to file to play
title: Display title for the file
headers: Optional HTTP headers (dict)
append: If True, append to playlist; if False, replace
Returns:
True if successfully sent to existing MPV, False if pipe unavailable.
"""
# Try to connect using the robust client
client = get_mpv_client()
if not client:
return False
try:
# Command 1: Set headers if provided
if headers:
header_str = ",".join([f"{k}: {v}" for k, v in headers.items()])
cmd_headers = {
"command": ["set_property", "http-header-fields", header_str],
"request_id": 0
}
client.send_command(cmd_headers)
# Command 2: Load file
# Use memory:// M3U to preserve title in playlist if provided
# 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}\n"
target = f"memory://{m3u_content}"
else:
target = file_url
load_mode = "append-play" if append else "replace"
cmd_load = {
"command": ["loadfile", target, load_mode],
"request_id": 1
}
resp = client.send_command(cmd_load)
if not resp or resp.get('error') != 'success':
debug(f"MPV loadfile failed: {resp}")
return False
# Command 3: Set title (metadata for display) - still useful for window title
if title:
safe_title_prop = title.replace('"', '\\"')
cmd_title = {
"command": ["set_property", "force-media-title", safe_title_prop],
"request_id": 2
}
client.send_command(cmd_title)
debug(f"Sent to existing MPV: {title}")
return True
except Exception as e:
debug(f"Error in send_to_mpv: {e}")
return False
finally:
client.disconnect()
def get_mpv_client(socket_path: Optional[str] = None) -> Optional[MPVIPCClient]:
"""Get an MPV IPC client, attempting to connect.
Args:
socket_path: Custom socket path (uses default if None)
Returns:
Connected MPVIPCClient or None if connection fails.
"""
client = MPVIPCClient(socket_path=socket_path)
if client.connect():
return client
return None

View File

@@ -1660,7 +1660,7 @@ class FileProvider(ABC):
self.name = self.__class__.__name__.replace("FileProvider", "").lower()
@abstractmethod
def upload(self, file_path: str) -> str:
def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload a file and return the URL."""
pass
@@ -1677,7 +1677,7 @@ class ZeroXZeroFileProvider(FileProvider):
self.name = "0x0"
self.base_url = "https://0x0.st"
def upload(self, file_path: str) -> str:
def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload file to 0x0.st."""
from helper.http_client import HTTPClient
import os
@@ -1707,9 +1707,137 @@ class ZeroXZeroFileProvider(FileProvider):
return True
class MatrixFileProvider(FileProvider):
"""File provider for Matrix (Element) chat rooms."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
self.name = "matrix"
def validate(self) -> bool:
"""Check if Matrix is configured."""
if not self.config: return False
matrix_conf = self.config.get('storage', {}).get('matrix', {})
return bool(matrix_conf.get('homeserver') and matrix_conf.get('room_id') and (matrix_conf.get('access_token') or matrix_conf.get('password')))
def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload file to Matrix room."""
import requests
import mimetypes
from pathlib import Path
import json
debug(f"[Matrix] Starting upload for: {file_path}")
debug(f"[Matrix] kwargs: {kwargs}")
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
matrix_conf = self.config.get('storage', {}).get('matrix', {})
homeserver = matrix_conf.get('homeserver')
access_token = matrix_conf.get('access_token')
room_id = matrix_conf.get('room_id')
if not homeserver.startswith('http'):
homeserver = f"https://{homeserver}"
# 1. Upload Media
# Use v3 API
upload_url = f"{homeserver}/_matrix/media/v3/upload"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/octet-stream"
}
mime_type, _ = mimetypes.guess_type(path)
if mime_type:
headers["Content-Type"] = mime_type
filename = path.name
debug(f"[Matrix] Uploading media to {upload_url} with mime_type: {mime_type}")
with open(path, 'rb') as f:
resp = requests.post(upload_url, headers=headers, data=f, params={"filename": filename})
if resp.status_code != 200:
raise Exception(f"Matrix upload failed: {resp.text}")
content_uri = resp.json().get('content_uri')
if not content_uri:
raise Exception("No content_uri returned from Matrix upload")
debug(f"[Matrix] Media uploaded, content_uri: {content_uri}")
# 2. Send Message
# Use v3 API
send_url = f"{homeserver}/_matrix/client/v3/rooms/{room_id}/send/m.room.message"
# Determine msgtype with better fallback for audio
msgtype = "m.file"
ext = path.suffix.lower()
# Explicit check for common audio extensions to force m.audio
# This prevents audio files being treated as generic files or video
AUDIO_EXTS = {'.mp3', '.flac', '.wav', '.m4a', '.aac', '.ogg', '.opus', '.wma', '.mka', '.alac'}
VIDEO_EXTS = {'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.mpg', '.mpeg', '.ts', '.m4v', '.wmv'}
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'}
if ext in AUDIO_EXTS:
msgtype = "m.audio"
elif ext in VIDEO_EXTS:
msgtype = "m.video"
elif ext in IMAGE_EXTS:
msgtype = "m.image"
elif mime_type:
if mime_type.startswith("audio/"): msgtype = "m.audio"
elif mime_type.startswith("video/"): msgtype = "m.video"
elif mime_type.startswith("image/"): msgtype = "m.image"
debug(f"[Matrix] Determined msgtype: {msgtype} (ext: {ext}, mime: {mime_type})")
info = {
"mimetype": mime_type,
"size": path.stat().st_size
}
# Try to get duration for audio/video
if msgtype in ("m.audio", "m.video"):
try:
# Try mutagen first (lightweight)
# Use dynamic import to avoid top-level dependency if not installed
# Note: mutagen.File is available at package level at runtime but type checkers might miss it
import mutagen # type: ignore
m = mutagen.File(str(path)) # type: ignore
if m and m.info and hasattr(m.info, 'length'):
duration_ms = int(m.info.length * 1000)
info['duration'] = duration_ms
debug(f"[Matrix] Extracted duration: {duration_ms}ms")
except Exception as e:
debug(f"[Matrix] Failed to extract duration: {e}")
payload = {
"msgtype": msgtype,
"body": filename,
"url": content_uri,
"info": info
}
debug(f"[Matrix] Sending message payload: {json.dumps(payload, indent=2)}")
resp = requests.post(send_url, headers=headers, json=payload)
if resp.status_code != 200:
raise Exception(f"Matrix send message failed: {resp.text}")
event_id = resp.json().get('event_id')
return f"https://matrix.to/#/{room_id}/{event_id}"
# File provider registry
_FILE_PROVIDERS = {
"0x0": ZeroXZeroFileProvider,
"matrix": MatrixFileProvider,
}

View File

@@ -7,7 +7,7 @@ disables Hydrus features if the API is unavailable.
import logging
import sys
from helper.logger import log
from helper.logger import log, debug
from typing import Tuple, Optional, Dict, Any
from pathlib import Path
@@ -28,6 +28,11 @@ _MPV_AVAILABLE: Optional[bool] = None
_MPV_UNAVAILABLE_REASON: Optional[str] = None
_MPV_CHECK_COMPLETE = False
# Global state for Matrix availability
_MATRIX_AVAILABLE: Optional[bool] = None
_MATRIX_UNAVAILABLE_REASON: Optional[str] = None
_MATRIX_CHECK_COMPLETE = False
def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Hydrus API is available by pinging it.
@@ -80,20 +85,16 @@ def initialize_hydrus_health_check(config: Dict[str, Any]) -> None:
_HYDRUS_CHECK_COMPLETE = True
if is_available:
log("✅ Hydrus: ENABLED - All Hydrus features available", file=sys.stderr)
debug("✅ Hydrus: ENABLED - All Hydrus features available", file=sys.stderr)
else:
log(f"⚠️ Hydrus: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
log("- Export functionality disabled", file=sys.stderr)
log("- Hydrus library features disabled", file=sys.stderr)
log("- Hydrus tag operations disabled", file=sys.stderr)
log("→ Local storage and All-Debrid features still available", file=sys.stderr)
debug(f"⚠️ Hydrus: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
except Exception as e:
logger.error(f"[Startup] Failed to initialize Hydrus health check: {e}", exc_info=True)
_HYDRUS_AVAILABLE = False
_HYDRUS_UNAVAILABLE_REASON = str(e)
_HYDRUS_CHECK_COMPLETE = True
log(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]]:
@@ -176,13 +177,10 @@ def initialize_debrid_health_check(config: Dict[str, Any]) -> None:
_DEBRID_CHECK_COMPLETE = True
if is_available:
log("✅ Debrid: ENABLED - All Debrid features available", file=sys.stderr)
debug("✅ Debrid: ENABLED - All Debrid features available", file=sys.stderr)
logger.info("[Startup] Debrid health check PASSED")
else:
log(f"⚠️ Debrid: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
log("- Debrid export disabled", file=sys.stderr)
log("- Debrid library features disabled", file=sys.stderr)
log("→ Local storage and Hydrus features still available", file=sys.stderr)
debug(f"⚠️ Debrid: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
logger.warning(f"[Startup] Debrid health check FAILED: {reason}")
except Exception as e:
@@ -190,7 +188,7 @@ def initialize_debrid_health_check(config: Dict[str, Any]) -> None:
_DEBRID_AVAILABLE = False
_DEBRID_UNAVAILABLE_REASON = str(e)
_DEBRID_CHECK_COMPLETE = True
log(f"⚠️ Debrid: DISABLED - Error during health check: {e}", file=sys.stderr)
debug(f"⚠️ Debrid: DISABLED - Error during health check: {e}", file=sys.stderr)
def check_mpv_availability() -> Tuple[bool, Optional[str]]:
@@ -263,11 +261,11 @@ def initialize_mpv_health_check() -> None:
_MPV_CHECK_COMPLETE = True
if is_available:
log("✅ MPV: ENABLED - All MPV features available", file=sys.stderr)
debug("✅ MPV: ENABLED - All MPV features available", file=sys.stderr)
logger.info("[Startup] MPV health check PASSED")
else:
log(f"⚠️ MPV: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
log("→ Hydrus features still available", file=sys.stderr)
debug(f"⚠️ MPV: DISABLED - {reason or 'Connection failed'}", file=sys.stderr)
debug("→ Hydrus features still available", file=sys.stderr)
logger.warning(f"[Startup] MPV health check FAILED: {reason}")
except Exception as e:
@@ -275,7 +273,77 @@ def initialize_mpv_health_check() -> None:
_MPV_AVAILABLE = False
_MPV_UNAVAILABLE_REASON = str(e)
_MPV_CHECK_COMPLETE = True
log(f"⚠️ MPV: DISABLED - Error during health check: {e}", file=sys.stderr)
debug(f"⚠️ MPV: DISABLED - Error during health check: {e}", file=sys.stderr)
def check_matrix_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Check if Matrix homeserver is reachable and credentials are valid.
Args:
config: Application configuration dictionary
Returns:
Tuple of (is_available: bool, reason: Optional[str])
"""
try:
import requests
matrix_conf = config.get('storage', {}).get('matrix', {})
homeserver = matrix_conf.get('homeserver')
access_token = matrix_conf.get('access_token')
if not homeserver:
return False, "Not configured"
if not homeserver.startswith('http'):
homeserver = f"https://{homeserver}"
# Check versions endpoint (no auth required)
try:
resp = requests.get(f"{homeserver}/_matrix/client/versions", timeout=5)
if resp.status_code != 200:
return False, f"Homeserver returned {resp.status_code}"
except Exception as e:
return False, f"Homeserver unreachable: {e}"
# Check auth if token provided (whoami)
if access_token:
try:
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(f"{homeserver}/_matrix/client/v3/account/whoami", headers=headers, timeout=5)
if resp.status_code != 200:
return False, f"Authentication failed: {resp.status_code}"
except Exception as e:
return False, f"Auth check failed: {e}"
return True, None
except Exception as e:
return False, str(e)
def initialize_matrix_health_check(config: Dict[str, Any]) -> None:
"""Initialize Matrix health check at startup."""
global _MATRIX_AVAILABLE, _MATRIX_UNAVAILABLE_REASON, _MATRIX_CHECK_COMPLETE
logger.info("[Startup] Starting Matrix health check...")
try:
is_available, reason = check_matrix_availability(config)
_MATRIX_AVAILABLE = is_available
_MATRIX_UNAVAILABLE_REASON = reason
_MATRIX_CHECK_COMPLETE = True
if is_available:
debug("Matrix: ENABLED - Homeserver reachable", file=sys.stderr)
else:
if reason != "Not configured":
debug(f"Matrix: DISABLED - {reason}", file=sys.stderr)
except Exception as e:
logger.error(f"[Startup] Failed to initialize Matrix health check: {e}", exc_info=True)
_MATRIX_AVAILABLE = False
_MATRIX_UNAVAILABLE_REASON = str(e)
_MATRIX_CHECK_COMPLETE = True
def is_hydrus_available() -> bool:
@@ -423,3 +491,52 @@ def enable_mpv_features() -> None:
_MPV_AVAILABLE = True
_MPV_UNAVAILABLE_REASON = None
logger.info("[MPV] Features manually enabled")
def is_matrix_available() -> bool:
"""Check if Matrix is available (from cached health check).
Returns:
True if Matrix is available, False otherwise
"""
return _MATRIX_AVAILABLE is True
def get_matrix_unavailable_reason() -> Optional[str]:
"""Get the reason why Matrix is unavailable.
Returns:
String explaining why Matrix is unavailable, or None if available
"""
return _MATRIX_UNAVAILABLE_REASON if not is_matrix_available() else None
def is_matrix_check_complete() -> bool:
"""Check if the Matrix health check has been completed.
Returns:
True if health check has run, False if still pending
"""
return _MATRIX_CHECK_COMPLETE
def disable_matrix_features() -> None:
"""Manually disable all Matrix features (for testing/fallback).
This can be called if Matrix connectivity is lost after startup.
"""
global _MATRIX_AVAILABLE, _MATRIX_UNAVAILABLE_REASON
_MATRIX_AVAILABLE = False
_MATRIX_UNAVAILABLE_REASON = "Manually disabled or lost connection"
logger.warning("[Matrix] Features manually disabled")
def enable_matrix_features() -> None:
"""Manually enable Matrix features (for testing/fallback).
This can be called if Matrix connectivity is restored after startup.
"""
global _MATRIX_AVAILABLE, _MATRIX_UNAVAILABLE_REASON
_MATRIX_AVAILABLE = True
_MATRIX_UNAVAILABLE_REASON = None
logger.info("[Matrix] Features manually enabled")

View File

@@ -5,7 +5,7 @@ import sys
import shutil
import sqlite3
import requests
from helper.logger import log
from helper.logger import log, debug
from urllib.parse import urlsplit, urlunsplit, unquote
from collections import deque
from pathlib import Path
@@ -1312,7 +1312,7 @@ def _read_sidecar_metadata(sidecar_path: Path) -> tuple[Optional[str], List[str]
def rename_by_metadata(file_path: Path, tags: Iterable[str]) -> Optional[Path]:
def rename(file_path: Path, tags: Iterable[str]) -> Optional[Path]:
"""Rename a file based on title: tag in the tags list.
If a title: tag is present, renames the file and any .tags/.metadata sidecars.
@@ -1350,13 +1350,13 @@ def rename_by_metadata(file_path: Path, tags: Iterable[str]) -> Optional[Path]:
if new_path.exists():
try:
new_path.unlink()
log(f"[rename_by_metadata] Replaced existing file: {new_name}", file=sys.stderr)
debug(f"Replaced existing file: {new_name}", file=sys.stderr)
except Exception as e:
log(f"[rename_by_metadata] Warning: Could not replace target file {new_name}: {e}", file=sys.stderr)
debug(f"Warning: Could not replace target file {new_name}: {e}", file=sys.stderr)
return None
file_path.rename(new_path)
log(f"[rename_by_metadata] Renamed file: {old_name}{new_name}", file=sys.stderr)
debug(f"Renamed file: {old_name}{new_name}", file=sys.stderr)
# Rename the .tags sidecar if it exists
old_tags_path = file_path.parent / (old_name + '.tags')
@@ -1369,21 +1369,21 @@ def rename_by_metadata(file_path: Path, tags: Iterable[str]) -> Optional[Path]:
pass
else:
old_tags_path.rename(new_tags_path)
log(f"[rename_by_metadata] Renamed sidecar: {old_tags_path.name}{new_tags_path.name}", file=sys.stderr)
debug(f"Renamed sidecar: {old_tags_path.name}{new_tags_path.name}", file=sys.stderr)
# Rename the .metadata sidecar if it exists
old_metadata_path = file_path.parent / (old_name + '.metadata')
if old_metadata_path.exists():
new_metadata_path = file_path.parent / (new_name + '.metadata')
if new_metadata_path.exists():
log(f"[rename_by_metadata] Warning: Target metadata already exists: {new_metadata_path.name}", file=sys.stderr)
debug(f"Warning: Target metadata already exists: {new_metadata_path.name}", file=sys.stderr)
else:
old_metadata_path.rename(new_metadata_path)
log(f"[rename_by_metadata] Renamed metadata: {old_metadata_path.name}{new_metadata_path.name}", file=sys.stderr)
debug(f"Renamed metadata: {old_metadata_path.name}{new_metadata_path.name}", file=sys.stderr)
return new_path
except Exception as exc:
log(f"[rename_by_metadata] Warning: Failed to rename file: {exc}", file=sys.stderr)
debug(f"Warning: Failed to rename file: {exc}", file=sys.stderr)
return None
@@ -1419,10 +1419,10 @@ def write_tags(media_path: Path, tags: Iterable[str], known_urls: Iterable[str],
if db_tags:
db.add_tags(media_path, db_tags)
log(f"Added tags to database for {media_path.name}")
debug(f"Added tags to database for {media_path.name}")
return
except Exception as e:
log(f"Failed to add tags to database: {e}", file=sys.stderr)
debug(f"Failed to add tags to database: {e}", file=sys.stderr)
# Fall through to sidecar creation as fallback
# Create sidecar path
@@ -1449,7 +1449,7 @@ def write_tags(media_path: Path, tags: Iterable[str], known_urls: Iterable[str],
if lines:
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
log(f"Wrote tags to {sidecar}")
debug(f"Tags: {sidecar}")
# Clean up legacy files
for legacy_path in [media_path.with_name(media_path.name + '.tags'),
media_path.with_name(media_path.name + '.tags.txt')]:
@@ -1464,7 +1464,7 @@ def write_tags(media_path: Path, tags: Iterable[str], known_urls: Iterable[str],
except FileNotFoundError:
pass
except OSError as exc:
log(f"Failed to write tag sidecar {sidecar}: {exc}", file=sys.stderr)
debug(f"Failed to write tag sidecar {sidecar}: {exc}", file=sys.stderr)
def write_metadata(media_path: Path, hash_value: Optional[str] = None, known_urls: Optional[Iterable[str]] = None, relationships: Optional[Iterable[str]] = None, db=None) -> None:
@@ -1503,10 +1503,10 @@ def write_metadata(media_path: Path, hash_value: Optional[str] = None, known_url
if db_tags:
db.add_tags(media_path, db_tags)
log(f"Added metadata to database for {media_path.name}")
debug(f"Added metadata to database for {media_path.name}")
return
except Exception as e:
log(f"Failed to add metadata to database: {e}", file=sys.stderr)
debug(f"Failed to add metadata to database: {e}", file=sys.stderr)
# Fall through to sidecar creation as fallback
# Create sidecar path
@@ -1535,7 +1535,7 @@ def write_metadata(media_path: Path, hash_value: Optional[str] = None, known_url
# Write metadata file
if lines:
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
log(f"Wrote metadata to {sidecar}")
debug(f"Wrote metadata to {sidecar}")
else:
# Remove if no content
try:
@@ -1543,7 +1543,7 @@ def write_metadata(media_path: Path, hash_value: Optional[str] = None, known_url
except FileNotFoundError:
pass
except OSError as exc:
log(f"Failed to write metadata sidecar {sidecar}: {exc}", file=sys.stderr)
debug(f"Failed to write metadata sidecar {sidecar}: {exc}", file=sys.stderr)
def extract_title(tags: Iterable[str]) -> Optional[str]:
@@ -1892,7 +1892,7 @@ def extract_ytdlp_tags(entry: Dict[str, Any]) -> List[str]:
Example:
>>> entry = {'artist': 'The Beatles', 'album': 'Abbey Road', 'duration': 5247}
>>> tags = extract_ytdlp_tags(entry)
>>> log(tags)
>>> debug(tags)
['artist:The Beatles', 'album:Abbey Road']
"""
tags: List[str] = []
@@ -1986,7 +1986,7 @@ def dedup_tags_by_namespace(tags: List[str], keep_first: bool = True) -> List[st
... 'album:Abbey Road', 'artist:Beatles'
... ]
>>> dedup = dedup_tags_by_namespace(tags)
>>> log(dedup)
>>> debug(dedup)
['artist:Beatles', 'album:Abbey Road', 'tag:rock']
"""
if not tags:
@@ -2053,7 +2053,7 @@ def merge_multiple_tag_lists(
>>> list1 = ['artist:Beatles', 'album:Abbey Road']
>>> list2 = ['artist:Beatles', 'album:Abbey Road', 'tag:rock']
>>> merged = merge_multiple_tag_lists([list1, list2])
>>> log(merged)
>>> debug(merged)
['artist:Beatles', 'album:Abbey Road', 'tag:rock']
"""
if not sources:
@@ -2137,7 +2137,7 @@ def read_tags_from_file(file_path: Path) -> List[str]:
Example:
>>> tags = read_tags_from_file(Path('file.txt.tags'))
>>> log(tags)
>>> debug(tags)
['artist:Beatles', 'album:Abbey Road']
"""
file_path = Path(file_path)
@@ -2271,7 +2271,7 @@ def embed_metadata_in_file(
# Check if FFmpeg is available
ffmpeg_path = shutil.which('ffmpeg')
if not ffmpeg_path:
log(f"⚠️ FFmpeg not found; cannot embed metadata in {file_path.name}", file=sys.stderr)
debug(f"⚠️ FFmpeg not found; cannot embed metadata in {file_path.name}", file=sys.stderr)
return False
# Create temporary file for output
@@ -2294,18 +2294,18 @@ def embed_metadata_in_file(
# Replace original with temp file
file_path.unlink()
temp_file.rename(file_path)
log(f"✅ Embedded metadata in file: {file_path.name}", file=sys.stderr)
debug(f"✅ Embedded metadata in file: {file_path.name}", file=sys.stderr)
return True
else:
# Clean up temp file if it exists
if temp_file.exists():
temp_file.unlink()
log(f"❌ FFmpeg metadata embedding failed for {file_path.name}", file=sys.stderr)
debug(f"❌ FFmpeg metadata embedding failed for {file_path.name}", file=sys.stderr)
if result.stderr:
# Safely decode stderr, ignoring invalid UTF-8 bytes
try:
stderr_text = result.stderr.decode('utf-8', errors='replace')[:200]
log(f"FFmpeg stderr: {stderr_text}", file=sys.stderr)
debug(f"FFmpeg stderr: {stderr_text}", file=sys.stderr)
except Exception:
pass
return False
@@ -2315,7 +2315,7 @@ def embed_metadata_in_file(
temp_file.unlink()
except Exception:
pass
log(f"❌ Error embedding metadata: {exc}", file=sys.stderr)
debug(f"❌ Error embedding metadata: {exc}", file=sys.stderr)
return False
@@ -2402,7 +2402,7 @@ def normalize_tags_from_source(
Example:
>>> entry = {'artist': 'Beatles', 'album': 'Abbey Road'}
>>> tags = normalize_tags_from_source(entry, 'ytdlp')
>>> log(tags)
>>> debug(tags)
['artist:Beatles', 'album:Abbey Road']
"""
if source_type == 'auto':
@@ -2600,10 +2600,10 @@ def imdb(imdb_id: str = typer.Argument(..., help="IMDb identifier (ttXXXXXXX)"))
"""Lookup an IMDb title."""
try:
result = imdb_tag(imdb_id)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(help="Lookup a MusicBrainz entity")
@@ -2614,10 +2614,10 @@ def musicbrainz(
"""Lookup a MusicBrainz entity."""
try:
result = fetch_musicbrainz_tags(mbid, entity)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="remote-tags", help="Normalize a remote metadata payload")
@@ -2633,10 +2633,10 @@ def remote_tags(payload: Optional[str] = typer.Option(None, "--payload", help="J
if context and not isinstance(context, dict):
raise ValueError("context must be an object")
result = build_remote_bundle(metadata, existing, context)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="remote-fetch", help="Resolve remote metadata bundle")
@@ -2645,10 +2645,10 @@ def remote_fetch(payload: Optional[str] = typer.Option(None, "--payload", help="
try:
payload_data = _load_payload(payload)
result = resolve_remote_metadata(payload_data)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="expand-tag", help="Expand metadata references into tags")
@@ -2657,10 +2657,10 @@ def expand_tag(payload: Optional[str] = typer.Option(None, "--payload", help="JS
try:
payload_data = _load_payload(payload)
result = expand_metadata_tag(payload_data)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="hydrus-fetch", help="Fetch Hydrus metadata for a file")
@@ -2669,10 +2669,10 @@ def hydrus_fetch(payload: Optional[str] = typer.Option(None, "--payload", help="
try:
payload_data = _load_payload(payload)
result = fetch_hydrus_metadata(payload_data)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="hydrus-fetch-url", help="Fetch Hydrus metadata using a source URL")
@@ -2681,10 +2681,10 @@ def hydrus_fetch_url(payload: Optional[str] = typer.Option(None, "--payload", he
try:
payload_data = _load_payload(payload)
result = fetch_hydrus_metadata_by_url(payload_data)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="sync-sidecar", help="Synchronise .tags sidecar with supplied data")
@@ -2693,10 +2693,10 @@ def sync_sidecar_cmd(payload: Optional[str] = typer.Option(None, "--payload", he
try:
payload_data = _load_payload(payload)
result = sync_sidecar(payload_data)
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
@app.command(name="update-tag", help="Update or rename a tag")
@@ -2705,10 +2705,10 @@ def update_tag_cmd(payload: Optional[str] = typer.Option(None, "--payload", help
try:
payload_data = _load_payload(payload)
result = apply_tag_mutation(payload_data, 'update')
log(json.dumps(result, ensure_ascii=False), flush=True)
debug(json.dumps(result, ensure_ascii=False), flush=True)
except Exception as exc:
error_payload = {"error": str(exc)}
log(json.dumps(error_payload, ensure_ascii=False), flush=True)
debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1)
def main(argv: Optional[List[str]] = None) -> int:
@@ -3102,7 +3102,7 @@ def fetch_openlibrary_metadata_tags(isbn: Optional[str] = None, olid: Optional[s
metadata_tags.append(subject_clean)
except Exception as e:
log(f"⚠ Failed to fetch OpenLibrary metadata: {e}")
debug(f"⚠ Failed to fetch OpenLibrary metadata: {e}")
return metadata_tags

View File

@@ -251,8 +251,22 @@ class ResultTable:
def _add_search_result(self, row: ResultRow, result: Any) -> None:
"""Extract and add SearchResult fields to row."""
# Core fields
if hasattr(result, 'title') and result.title:
row.add_column("Title", result.title)
title = getattr(result, 'title', '')
origin = getattr(result, 'origin', '').lower()
# Handle extension separation for local files
extension = ""
if title and origin == 'local':
path_obj = Path(title)
if path_obj.suffix:
extension = path_obj.suffix.lstrip('.')
title = path_obj.stem
if title:
row.add_column("Title", title)
# Extension column
row.add_column("Ext", extension)
if hasattr(result, 'origin') and result.origin:
row.add_column("Source", result.origin)
@@ -263,18 +277,6 @@ class ResultTable:
if hasattr(result, 'media_kind') and result.media_kind:
row.add_column("Type", result.media_kind)
# Target (file path or URL)
if hasattr(result, 'target') and result.target:
# Truncate long paths for display
target_str = str(result.target)
if len(target_str) > 60:
target_str = "..." + target_str[-57:]
row.add_column("Target", target_str)
# Hash
if hasattr(result, 'hash_hex') and result.hash_hex:
row.add_column("Hash", result.hash_hex[:16] + "...") # First 16 chars
# Tags summary
if hasattr(result, 'tag_summary') and result.tag_summary:
tags_str = str(result.tag_summary)
@@ -305,6 +307,7 @@ class ResultTable:
Shows only essential columns:
- Title (required)
- Ext (extension)
- Origin (source backend)
- Size (formatted MB, integer only)
@@ -313,9 +316,23 @@ class ResultTable:
"""
# Title (required - use origin as fallback)
title = getattr(item, 'title', None) or getattr(item, 'origin', 'Unknown')
origin = getattr(item, 'origin', '').lower()
# Handle extension separation for local files
extension = ""
if title and origin == 'local':
# Try to split extension
path_obj = Path(title)
if path_obj.suffix:
extension = path_obj.suffix.lstrip('.')
title = path_obj.stem
if title:
row.add_column("Title", title[:90] + ("..." if len(title) > 90 else ""))
# Extension column - always add to maintain column order
row.add_column("Ext", extension)
# Storage (source backend - hydrus, local, debrid, etc)
if hasattr(item, 'origin') and item.origin:
row.add_column("Storage", item.origin)
@@ -364,9 +381,6 @@ class ResultTable:
file_str = "..." + file_str[-57:]
row.add_column("Path", file_str)
if hasattr(obj, 'file_hash') and obj.file_hash:
row.add_column("Hash", obj.file_hash[:16] + "...")
# Tags
if hasattr(obj, 'tags') and obj.tags:
tags_str = ", ".join(obj.tags[:3]) # First 3 tags
@@ -406,7 +420,10 @@ class ResultTable:
# Helper to determine if a field should be hidden from display
def is_hidden_field(field_name: Any) -> bool:
# Hide internal/metadata fields
hidden_fields = {'__', 'id', 'action', 'parent_id', 'is_temp', 'file_path', 'extra'}
hidden_fields = {
'__', 'id', 'action', 'parent_id', 'is_temp', 'file_path', 'extra',
'target', 'hash', 'hash_hex', 'file_hash'
}
if isinstance(field_name, str):
if field_name.startswith('__'):
return True
@@ -417,6 +434,30 @@ class ResultTable:
# Strip out hidden metadata fields (prefixed with __)
visible_data = {k: v for k, v in data.items() if not is_hidden_field(k)}
# Handle extension separation for local files
origin = str(visible_data.get('origin', '') or visible_data.get('source', '')).lower()
# Debug logging
# print(f"DEBUG: Processing dict result. Origin: {origin}, Keys: {list(visible_data.keys())}")
if origin == 'local':
# Find title field
title_field = next((f for f in ['title', 'name', 'filename'] if f in visible_data), None)
if title_field:
title_val = str(visible_data[title_field])
path_obj = Path(title_val)
if path_obj.suffix:
extension = path_obj.suffix.lstrip('.')
visible_data[title_field] = path_obj.stem
visible_data['ext'] = extension
# print(f"DEBUG: Split extension. Title: {visible_data[title_field]}, Ext: {extension}")
else:
visible_data['ext'] = ""
# Ensure 'ext' is present so it gets picked up by priority_groups in correct order
if 'ext' not in visible_data:
visible_data['ext'] = ""
# Track which fields we've already added to avoid duplicates
added_fields = set()
column_count = 0 # Track total columns added
@@ -467,10 +508,9 @@ class ResultTable:
# Priority field groups - uses first matching field in each group
priority_groups = [
('title | name | filename', ['title', 'name', 'filename']),
('origin | source', ['origin', 'source']),
('ext', ['ext']),
('origin | source | store', ['origin', 'source', 'store']),
('type | media_kind | kind', ['type', 'media_kind', 'kind']),
('target | path | url', ['target', 'path', 'url']),
('hash | hash_hex | file_hash', ['hash', 'hash_hex', 'file_hash']),
('tags | tag_summary', ['tags', 'tag_summary']),
('detail | description', ['detail', 'description']),
]
@@ -485,7 +525,12 @@ class ResultTable:
if len(value_str) > 60:
value_str = value_str[:57] + "..."
row.add_column(field.replace('_', ' ').title(), value_str)
# Special case for Origin/Source -> Store to match user preference
col_name = field.replace('_', ' ').title()
if field in ['origin', 'source']:
col_name = "Store"
row.add_column(col_name, value_str)
added_fields.add(field)
column_count += 1
break # Use first match in this group, skip rest
@@ -509,106 +554,6 @@ class ResultTable:
# Don't display it
added_fields.add('_selection_args')
# Helper to determine if a field should be hidden from display
def is_hidden_field(field_name: Any) -> bool:
# Hide internal/metadata fields
hidden_fields = {'__', 'id', 'action', 'parent_id', 'is_temp', 'file_path', 'extra'}
if isinstance(field_name, str):
if field_name.startswith('__'):
return True
if field_name in hidden_fields:
return True
return False
# Strip out hidden metadata fields (prefixed with __)
visible_data = {k: v for k, v in data.items() if not is_hidden_field(k)}
# Track which fields we've already added to avoid duplicates
added_fields = set()
column_count = 0 # Track total columns added
# Helper function to format values
def format_value(value: Any) -> str:
if isinstance(value, list):
formatted = ", ".join(str(v) for v in value[:3])
if len(value) > 3:
formatted += f", +{len(value) - 3} more"
return formatted
return str(value)
# Special handling for 'columns' field from search providers
# If present, use it to populate row columns dynamically
if 'columns' in visible_data and isinstance(visible_data['columns'], list) and visible_data['columns']:
try:
for col_name, col_value in visible_data['columns']:
# Skip the "#" column as ResultTable already adds row numbers
if col_name == '#':
continue
if column_count >= self.max_columns:
break
col_value_str = format_value(col_value)
if len(col_value_str) > 60:
col_value_str = col_value_str[:57] + "..."
row.add_column(col_name, col_value_str)
added_fields.add(col_name.lower())
column_count += 1
# Mark 'columns' as handled so we don't add it as a field
added_fields.add('columns')
# Also mark common fields that shouldn't be re-displayed if they're in columns
# This prevents showing both "Store" (from columns) and "Origin" (from data fields)
added_fields.add('origin')
added_fields.add('source')
added_fields.add('target')
added_fields.add('path')
added_fields.add('media_kind')
added_fields.add('detail')
added_fields.add('annotations')
added_fields.add('full_metadata') # Don't display full metadata as column
except Exception:
# Fall back to regular field handling if columns format is unexpected
pass
# Only add priority groups if we haven't already filled columns from 'columns' field
if column_count == 0:
# Priority field groups - uses first matching field in each group
priority_groups = [
('title | name | filename', ['title', 'name', 'filename']),
('origin | source', ['origin', 'source']),
('type | media_kind | kind', ['type', 'media_kind', 'kind']),
('target | path | url', ['target', 'path', 'url']),
('hash | hash_hex | file_hash', ['hash', 'hash_hex', 'file_hash']),
('tags | tag_summary', ['tags', 'tag_summary']),
('detail | description', ['detail', 'description']),
]
# Add priority field groups first - use first match in each group
for _group_label, field_options in priority_groups:
if column_count >= self.max_columns:
break
for field in field_options:
if field in visible_data and field not in added_fields:
value_str = format_value(visible_data[field])
if len(value_str) > 60:
value_str = value_str[:57] + "..."
row.add_column(field.replace('_', ' ').title(), value_str)
added_fields.add(field)
column_count += 1
break # Use first match in this group, skip rest
# Add remaining fields only if we haven't hit max_columns (and no explicit columns were set)
if column_count < self.max_columns:
for key, value in visible_data.items():
if column_count >= self.max_columns:
break
if key not in added_fields: # Only add if not already added
value_str = format_value(value)
if len(value_str) > 40:
value_str = value_str[:37] + "..."
row.add_column(key.replace('_', ' ').title(), value_str)
added_fields.add(key) # Track in added_fields to prevent re-adding
column_count += 1
def _add_generic_object(self, row: ResultRow, obj: Any) -> None:
"""Extract and add fields from generic objects."""
if hasattr(obj, '__dict__'):

23
test_search.py Normal file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
import sys
sys.path.insert(0, '.')
from helper.file_storage import LocalStorageBackend
from config import get_local_storage_path
import json
config = json.load(open('config.json'))
# Get the location string properly
location = get_local_storage_path(config)
if isinstance(location, dict):
location = location.get('path') or str(location)
backend = LocalStorageBackend(config)
# Test searches
for query in ['sie*', 'sie', '*']:
print(f"\n=== Searching for: {query} ===")
results = backend.search(query, location=str(location), limit=5)
print(f"Found {len(results)} results")
for r in results:
print(f" - {r.get('title')} ({r.get('ext')}) @ {r.get('path')}")