This commit is contained in:
nose
2025-12-11 23:21:45 -08:00
parent 16d8a763cd
commit e2ffcab030
44 changed files with 3558 additions and 1793 deletions
+19 -215
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import http.client
import json import json
import os import os
import re import re
@@ -21,18 +22,11 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: # Optional metadata helper for audio files
import mutagen # type: ignore
except ImportError: # pragma: no cover - best effort
mutagen = None # type: ignore
from SYS.utils import ( from SYS.utils import (
decode_cbor, decode_cbor,
jsonify, jsonify,
ensure_directory, ensure_directory,
sanitize_metadata_value,
unique_path, unique_path,
unique_preserve_order,
) )
from .HTTP import HTTPClient from .HTTP import HTTPClient
@@ -70,7 +64,7 @@ class HydrusRequestSpec:
@dataclass(slots=True) @dataclass(slots=True)
class HydrusClient: class HydrusNetwork:
"""Thin wrapper around the Hydrus Client API.""" """Thin wrapper around the Hydrus Client API."""
url: str url: str
@@ -311,10 +305,10 @@ class HydrusClient:
spec = HydrusRequestSpec("POST", endpoint, data=data, file_path=file_path, content_type=content_type) spec = HydrusRequestSpec("POST", endpoint, data=data, file_path=file_path, content_type=content_type)
return cast(dict[str, Any], self._perform_request(spec)) return cast(dict[str, Any], self._perform_request(spec))
def _ensure_hashes(self, hashes: Union[str, Iterable[str]]) -> list[str]: def _ensure_hashes(self, hash: Union[str, Iterable[str]]) -> list[str]:
if isinstance(hashes, str): if isinstance(hash, str):
return [hashes] return [hash]
return list(hashes) return list(hash)
def _append_access_key(self, url: str) -> str: def _append_access_key(self, url: str) -> str:
if not self.access_key: if not self.access_key:
@@ -330,12 +324,12 @@ class HydrusClient:
def add_file(self, file_path: Path) -> dict[str, Any]: def add_file(self, file_path: Path) -> dict[str, Any]:
return self._post("/add_files/add_file", file_path=file_path) return self._post("/add_files/add_file", file_path=file_path)
def add_tags(self, file_hashes: Union[str, Iterable[str]], tags: Iterable[str], service_name: str) -> dict[str, Any]: def add_tag(self, hash: Union[str, Iterable[str]], tags: Iterable[str], service_name: str) -> dict[str, Any]:
hashes = self._ensure_hashes(file_hashes) hash = self._ensure_hashes(hash)
body = {"hashes": hashes, "service_names_to_tags": {service_name: list(tags)}} body = {"hashes": hash, "service_names_to_tags": {service_name: list(tags)}}
return self._post("/add_tags/add_tags", data=body) return self._post("/add_tags/add_tags", data=body)
def delete_tags( def delete_tag(
self, self,
file_hashes: Union[str, Iterable[str]], file_hashes: Union[str, Iterable[str]],
tags: Iterable[str], tags: Iterable[str],
@@ -350,9 +344,9 @@ class HydrusClient:
} }
return self._post("/add_tags/add_tags", data=body) return self._post("/add_tags/add_tags", data=body)
def add_tags_by_key(self, file_hashes: Union[str, Iterable[str]], tags: Iterable[str], service_key: str) -> dict[str, Any]: def add_tags_by_key(self, hash: Union[str, Iterable[str]], tags: Iterable[str], service_key: str) -> dict[str, Any]:
hashes = self._ensure_hashes(file_hashes) hash = self._ensure_hashes(hash)
body = {"hashes": hashes, "service_keys_to_tags": {service_key: list(tags)}} body = {"hashes": hash, "service_keys_to_tags": {service_key: list(tags)}}
return self._post("/add_tags/add_tags", data=body) return self._post("/add_tags/add_tags", data=body)
def delete_tags_by_key( def delete_tags_by_key(
@@ -727,199 +721,9 @@ def hydrus_request(args, parser) -> int:
else: else:
log(json.dumps({'value': json_ready}, ensure_ascii=False)) log(json.dumps({'value': json_ready}, ensure_ascii=False))
return 0 if 200 <= status < 400 else 1 return 0 if 200 <= status < 400 else 1
def prepare_ffmpeg_metadata(payload: Optional[dict[str, Any]]) -> dict[str, str]:
if not isinstance(payload, dict):
return {}
metadata: dict[str, str] = {}
def set_field(key: str, raw: Any, limit: int = 2000) -> None:
sanitized = sanitize_metadata_value(raw)
if not sanitized:
return
if len(sanitized) > limit:
sanitized = sanitized[:limit]
metadata[key] = sanitized
set_field('title', payload.get('title'))
set_field('artist', payload.get('artist'), 512)
set_field('album', payload.get('album'), 512)
set_field('date', payload.get('year'), 20)
comment = payload.get('comment')
tags_value = payload.get('tags')
tag_strings: list[str] = []
artists_from_tags: list[str] = []
albums_from_tags: list[str] = []
genres_from_tags: list[str] = []
if isinstance(tags_value, list):
for raw_tag in tags_value:
if raw_tag is None:
continue
if not isinstance(raw_tag, str):
raw_tag = str(raw_tag)
tag = raw_tag.strip()
if not tag:
continue
tag_strings.append(tag)
namespace, sep, value = tag.partition(':')
if sep and value:
ns = namespace.strip().lower()
value = value.strip()
if ns in {'artist', 'creator', 'author', 'performer'}:
artists_from_tags.append(value)
elif ns in {'album', 'series', 'collection', 'group'}:
albums_from_tags.append(value)
elif ns in {'genre', 'rating'}:
genres_from_tags.append(value)
elif ns in {'comment', 'description'} and not comment:
comment = value
elif ns in {'year', 'date'} and not payload.get('year'):
set_field('date', value, 20)
else:
genres_from_tags.append(tag)
if 'artist' not in metadata and artists_from_tags:
set_field('artist', ', '.join(unique_preserve_order(artists_from_tags)[:3]), 512)
if 'album' not in metadata and albums_from_tags:
set_field('album', unique_preserve_order(albums_from_tags)[0], 512)
if genres_from_tags:
set_field('genre', ', '.join(unique_preserve_order(genres_from_tags)[:5]), 256)
if tag_strings:
joined_tags = ', '.join(tag_strings[:50])
set_field('keywords', joined_tags, 2000)
if not comment:
comment = joined_tags
if comment:
set_field('comment', comment, 2000)
set_field('description', comment, 2000)
return metadata
def apply_mutagen_metadata(path: Path, metadata: dict[str, str], fmt: str) -> None:
if fmt != 'audio':
return
if not metadata:
return
if mutagen is None:
return
try:
audio = mutagen.File(path, easy=True) # type: ignore[attr-defined]
except Exception as exc: # pragma: no cover - best effort only
log(f"mutagen load failed: {exc}", file=sys.stderr)
return
if audio is None:
return
field_map = {
'title': 'title',
'artist': 'artist',
'album': 'album',
'genre': 'genre',
'comment': 'comment',
'description': 'comment',
'date': 'date',
}
changed = False
for source_key, target_key in field_map.items():
value = metadata.get(source_key)
if not value:
continue
try:
audio[target_key] = [value]
changed = True
except Exception: # pragma: no cover - best effort only
continue
if not changed:
return
try:
audio.save()
except Exception as exc: # pragma: no cover - best effort only
log(f"mutagen save failed: {exc}", file=sys.stderr)
def build_ffmpeg_command(ffmpeg_path: str, input_path: Path, output_path: Path, fmt: str, max_width: int, metadata: Optional[dict[str, str]] = None) -> list[str]:
cmd = [ffmpeg_path, '-y', '-i', str(input_path)]
if fmt in {'mp4', 'webm'} and max_width and max_width > 0:
cmd.extend(['-vf', f"scale='min({max_width},iw)':-2"])
if metadata:
for key, value in metadata.items():
cmd.extend(['-metadata', f'{key}={value}'])
# Video formats
if fmt == 'mp4':
cmd.extend([
'-c:v', 'libx265',
'-preset', 'medium',
'-crf', '26',
'-tag:v', 'hvc1',
'-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+faststart',
])
elif fmt == 'webm':
cmd.extend([
'-c:v', 'libvpx-vp9',
'-b:v', '0',
'-crf', '32',
'-c:a', 'libopus',
'-b:a', '160k',
])
cmd.extend(['-f', 'webm'])
# Audio formats
elif fmt == 'mp3':
cmd.extend([
'-vn',
'-c:a', 'libmp3lame',
'-b:a', '192k',
])
cmd.extend(['-f', 'mp3'])
elif fmt == 'flac':
cmd.extend([
'-vn',
'-c:a', 'flac',
])
cmd.extend(['-f', 'flac'])
elif fmt == 'wav':
cmd.extend([
'-vn',
'-c:a', 'pcm_s16le',
])
cmd.extend(['-f', 'wav'])
elif fmt == 'aac':
cmd.extend([
'-vn',
'-c:a', 'aac',
'-b:a', '192k',
])
cmd.extend(['-f', 'adts'])
elif fmt == 'm4a':
cmd.extend([
'-vn',
'-c:a', 'aac',
'-b:a', '192k',
])
cmd.extend(['-f', 'ipod'])
elif fmt == 'ogg':
cmd.extend([
'-vn',
'-c:a', 'libvorbis',
'-b:a', '192k',
])
cmd.extend(['-f', 'ogg'])
elif fmt == 'opus':
cmd.extend([
'-vn',
'-c:a', 'libopus',
'-b:a', '192k',
])
cmd.extend(['-f', 'opus'])
elif fmt == 'audio':
# Legacy format name for mp3
cmd.extend([
'-vn',
'-c:a', 'libmp3lame',
'-b:a', '192k',
])
cmd.extend(['-f', 'mp3'])
elif fmt != 'copy':
raise ValueError(f'Unsupported format: {fmt}')
cmd.append(str(output_path))
return cmd
def hydrus_export(args, _parser) -> int: def hydrus_export(args, _parser) -> int:
from metadata import apply_mutagen_metadata, build_ffmpeg_command, prepare_ffmpeg_metadata
output_path: Path = args.output output_path: Path = args.output
original_suffix = output_path.suffix original_suffix = output_path.suffix
target_dir = output_path.parent target_dir = output_path.parent
@@ -1064,7 +868,7 @@ def hydrus_export(args, _parser) -> int:
file_hash = getattr(args, 'file_hash', None) or _extract_hash(args.file_url) file_hash = getattr(args, 'file_hash', None) or _extract_hash(args.file_url)
if hydrus_url and file_hash: if hydrus_url and file_hash:
try: try:
client = HydrusClient(url=hydrus_url, access_key=args.access_key, timeout=args.timeout) client = HydrusNetwork(url=hydrus_url, access_key=args.access_key, timeout=args.timeout)
meta_response = client.fetch_file_metadata(hashes=[file_hash], include_mime=True) meta_response = client.fetch_file_metadata(hashes=[file_hash], include_mime=True)
entries = meta_response.get('metadata') if isinstance(meta_response, dict) else None entries = meta_response.get('metadata') if isinstance(meta_response, dict) else None
if isinstance(entries, list) and entries: if isinstance(entries, list) and entries:
@@ -1387,7 +1191,7 @@ def is_hydrus_available(config: dict[str, Any]) -> bool:
return available return available
def get_client(config: dict[str, Any]) -> HydrusClient: def get_client(config: dict[str, Any]) -> HydrusNetwork:
"""Create and return a Hydrus client with session key authentication. """Create and return a Hydrus client with session key authentication.
Reuses cached client instance to preserve session keys across requests. Reuses cached client instance to preserve session keys across requests.
@@ -1440,7 +1244,7 @@ def get_client(config: dict[str, Any]) -> HydrusClient:
del _hydrus_client_cache[cache_key] del _hydrus_client_cache[cache_key]
# Create new client # Create new client
client = HydrusClient(hydrus_url, access_key, timeout) client = HydrusNetwork(hydrus_url, access_key, timeout)
# Acquire session key for secure authentication # Acquire session key for secure authentication
try: try:
@@ -1474,7 +1278,7 @@ def get_tag_service_name(config: dict[str, Any]) -> str:
return "my tags" return "my tags"
def get_tag_service_key(client: HydrusClient, fallback_name: str = "my tags") -> Optional[str]: def get_tag_service_key(client: HydrusNetwork, fallback_name: str = "my tags") -> Optional[str]:
"""Get the service key for a named tag service. """Get the service key for a named tag service.
Queries the Hydrus client's services and finds the service key matching Queries the Hydrus client's services and finds the service key matching
+13 -14
View File
@@ -3,7 +3,7 @@
This module provides: This module provides:
- SQLite database management for local file metadata caching - SQLite database management for local file metadata caching
- Library scanning and database initialization - Library scanning and database initialization
- Sidecar file migration from old .tags/.metadata files to database - Sidecar file migration from .tag/.metadata files to database
- Optimized search functionality using database indices - Optimized search functionality using database indices
- Worker task tracking for background operations - Worker task tracking for background operations
""" """
@@ -68,7 +68,7 @@ def read_sidecar(sidecar_path: Path) -> Tuple[Optional[str], List[str], List[str
Delegates to metadata._read_sidecar_metadata for centralized handling. Delegates to metadata._read_sidecar_metadata for centralized handling.
Args: Args:
sidecar_path: Path to .tags sidecar file sidecar_path: Path to .tag sidecar file
Returns: Returns:
Tuple of (hash_value, tags_list, url_list) Tuple of (hash_value, tags_list, url_list)
@@ -90,7 +90,7 @@ def write_sidecar(media_path: Path, tags: List[str], url: List[str],
Delegates to metadata.write_tags for centralized handling. Delegates to metadata.write_tags for centralized handling.
Args: Args:
media_path: Path to the media file (sidecar created as media_path.tags) media_path: Path to the media file (sidecar created as media_path.tag)
tags: List of tag strings tags: List of tag strings
url: List of known URL strings url: List of known URL strings
hash_value: Optional SHA256 hash to include hash_value: Optional SHA256 hash to include
@@ -129,7 +129,7 @@ def find_sidecar(media_path: Path) -> Optional[Path]:
return None return None
try: try:
# Check for new format: filename.ext.tags # Check for new format: filename.ext.tag
sidecar_path = _derive_sidecar_path(media_path) sidecar_path = _derive_sidecar_path(media_path)
if sidecar_path.exists(): if sidecar_path.exists():
return sidecar_path return sidecar_path
@@ -1861,8 +1861,7 @@ class LocalLibraryInitializer:
sidecar_map: Dict[Path, Dict[str, List[Path]]] = {} sidecar_map: Dict[Path, Dict[str, List[Path]]] = {}
patterns = [ patterns = [
("*.tag", "tags"), ("*.tag", "tag"),
("*.tags", "tags"),
("*.metadata", "metadata"), ("*.metadata", "metadata"),
("*.notes", "notes"), ("*.notes", "notes"),
] ]
@@ -1877,14 +1876,14 @@ class LocalLibraryInitializer:
if not base.exists(): if not base.exists():
continue continue
bucket = sidecar_map.setdefault(base, {"tags": [], "metadata": [], "notes": []}) bucket = sidecar_map.setdefault(base, {"tag": [], "metadata": [], "notes": []})
bucket[key].append(sidecar) bucket[key].append(sidecar)
return sidecar_map return sidecar_map
def _read_tag_sidecars(self, sidecars: Dict[str, List[Path]]) -> List[str]: def _read_tag_sidecars(self, sidecars: Dict[str, List[Path]]) -> List[str]:
tags: List[str] = [] tags: List[str] = []
for tag_path in sidecars.get("tags", []): for tag_path in sidecars.get("tag", []):
try: try:
content = tag_path.read_text(encoding="utf-8") content = tag_path.read_text(encoding="utf-8")
except OSError: except OSError:
@@ -1972,7 +1971,7 @@ class LocalLibraryInitializer:
def _rename_sidecars(self, old_base: Path, new_base: Path, sidecars: Dict[str, List[Path]]) -> None: def _rename_sidecars(self, old_base: Path, new_base: Path, sidecars: Dict[str, List[Path]]) -> None:
"""Rename sidecars to follow the new hashed filename.""" """Rename sidecars to follow the new hashed filename."""
mappings = [ mappings = [
(sidecars.get("tags", []), ".tag"), (sidecars.get("tag", []), ".tag"),
(sidecars.get("metadata", []), ".metadata"), (sidecars.get("metadata", []), ".metadata"),
(sidecars.get("notes", []), ".notes"), (sidecars.get("notes", []), ".notes"),
] ]
@@ -2006,7 +2005,7 @@ class LocalLibraryInitializer:
def _cleanup_orphaned_sidecars(self) -> None: def _cleanup_orphaned_sidecars(self) -> None:
"""Remove sidecars for non-existent files.""" """Remove sidecars for non-existent files."""
try: try:
patterns = ["*.tag", "*.tags", "*.metadata", "*.notes"] patterns = ["*.tag", "*.metadata", "*.notes"]
for pattern in patterns: for pattern in patterns:
for sidecar_path in self.library_root.rglob(pattern): for sidecar_path in self.library_root.rglob(pattern):
@@ -2022,13 +2021,13 @@ class LocalLibraryInitializer:
def migrate_tags_to_db(library_root: Path, db: API_folder_store) -> int: def migrate_tags_to_db(library_root: Path, db: API_folder_store) -> int:
"""Migrate .tags files to the database.""" """Migrate .tag files to the database."""
migrated_count = 0 migrated_count = 0
try: try:
for tags_file in library_root.rglob("*.tags"): for tags_file in library_root.rglob("*.tag"):
try: try:
base_path = Path(str(tags_file)[:-len('.tags')]) base_path = tags_file.with_suffix("")
tags_text = tags_file.read_text(encoding='utf-8') tags_text = tags_file.read_text(encoding='utf-8')
tags = [line.strip() for line in tags_text.splitlines() if line.strip()] tags = [line.strip() for line in tags_text.splitlines() if line.strip()]
@@ -2043,7 +2042,7 @@ def migrate_tags_to_db(library_root: Path, db: API_folder_store) -> int:
except Exception as e: except Exception as e:
logger.warning(f"Failed to migrate {tags_file}: {e}") logger.warning(f"Failed to migrate {tags_file}: {e}")
logger.info(f"Migrated {migrated_count} .tags files to database") logger.info(f"Migrated {migrated_count} .tag files to database")
return migrated_count return migrated_count
except Exception as e: except Exception as e:
logger.error(f"Error during tags migration: {e}", exc_info=True) logger.error(f"Error during tags migration: {e}", exc_info=True)
+12 -4
View File
@@ -227,8 +227,8 @@ def _get_table_title_for_command(cmd_name: str, emitted_items: Optional[List[Any
'get_tag': 'Tags', 'get_tag': 'Tags',
'get-file': 'Results', 'get-file': 'Results',
'get_file': 'Results', 'get_file': 'Results',
'add-tag': 'Results', 'add-tags': 'Results',
'add_tag': 'Results', 'add_tags': 'Results',
'delete-tag': 'Results', 'delete-tag': 'Results',
'delete_tag': 'Results', 'delete_tag': 'Results',
'add-url': 'Results', 'add-url': 'Results',
@@ -1362,12 +1362,20 @@ def _execute_pipeline(tokens: list):
print(f"Auto-piping YouTube selection to .pipe") print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe']) stages.append(['.pipe'])
elif table_type == 'soulseek': elif table_type == 'soulseek':
print(f"Auto-piping Soulseek selection to download-provider") print(f"Auto-piping Soulseek selection to download-file")
stages.append(['download-provider']) stages.append(['download-file'])
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args: elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Legacy check # Legacy check
print(f"Auto-piping YouTube selection to .pipe") print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe']) stages.append(['.pipe'])
else:
# If the user is piping a provider selection into additional stages (e.g. add-file),
# automatically insert the appropriate download stage so @N is "logical".
# This prevents add-file from receiving an unreachable provider path like "share\...".
first_cmd = stages[0][0] if stages and stages[0] else None
if table_type == 'soulseek' and first_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'):
print(f"Auto-inserting download-file after Soulseek selection")
stages.insert(0, ['download-file'])
else: else:
print(f"No items matched selection in pipeline\n") print(f"No items matched selection in pipeline\n")
+2 -2
View File
@@ -18,7 +18,7 @@ class SearchResult:
annotations: List[str] = field(default_factory=list) # Tags: ["120MB", "flac", "ready"] annotations: List[str] = field(default_factory=list) # Tags: ["120MB", "flac", "ready"]
media_kind: str = "other" # Type: "book", "audio", "video", "game", "magnet" media_kind: str = "other" # Type: "book", "audio", "video", "game", "magnet"
size_bytes: Optional[int] = None size_bytes: Optional[int] = None
tags: set[str] = field(default_factory=set) # Searchable tags tag: set[str] = field(default_factory=set) # Searchable tag values
columns: List[Tuple[str, str]] = field(default_factory=list) # Display columns columns: List[Tuple[str, str]] = field(default_factory=list) # Display columns
full_metadata: Dict[str, Any] = field(default_factory=dict) # Extra metadata full_metadata: Dict[str, Any] = field(default_factory=dict) # Extra metadata
@@ -33,7 +33,7 @@ class SearchResult:
"annotations": self.annotations, "annotations": self.annotations,
"media_kind": self.media_kind, "media_kind": self.media_kind,
"size_bytes": self.size_bytes, "size_bytes": self.size_bytes,
"tags": list(self.tags), "tag": list(self.tag),
"columns": list(self.columns), "columns": list(self.columns),
"full_metadata": self.full_metadata, "full_metadata": self.full_metadata,
} }
+154 -1
View File
@@ -1,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import io
import logging
import os
import re import re
import sys import sys
import time import time
@@ -11,6 +15,143 @@ from Provider._base import SearchProvider, SearchResult
from SYS.logger import log, debug from SYS.logger import log, debug
_SOULSEEK_NOISE_SUBSTRINGS = (
"search reply ticket does not match any search request",
"failed to receive transfer ticket on file connection",
"aioslsk.exceptions.ConnectionReadError",
)
def _configure_aioslsk_logging() -> None:
"""Reduce aioslsk internal log noise.
Some aioslsk components emit non-fatal warnings/errors during high churn
(search + download + disconnect). We keep our own debug output, but push
aioslsk to ERROR and stop propagation so it doesn't spam the CLI.
"""
for name in (
"aioslsk",
"aioslsk.network",
"aioslsk.search",
"aioslsk.transfer",
"aioslsk.transfer.manager",
):
logger = logging.getLogger(name)
logger.setLevel(logging.ERROR)
logger.propagate = False
class _LineFilterStream(io.TextIOBase):
"""A minimal stream wrapper that filters known noisy lines.
It also suppresses entire traceback blocks when they contain known non-fatal
aioslsk noise (e.g. ConnectionReadError during peer init).
"""
def __init__(self, underlying: Any, suppress_substrings: tuple[str, ...]):
super().__init__()
self._underlying = underlying
self._suppress = suppress_substrings
self._buf = ""
self._in_tb = False
self._tb_lines: list[str] = []
self._tb_suppress = False
def writable(self) -> bool: # pragma: no cover
return True
def _should_suppress_line(self, line: str) -> bool:
return any(sub in line for sub in self._suppress)
def _flush_tb(self) -> None:
if not self._tb_lines:
return
if not self._tb_suppress:
for l in self._tb_lines:
try:
self._underlying.write(l + "\n")
except Exception:
pass
self._tb_lines = []
self._tb_suppress = False
self._in_tb = False
def write(self, s: str) -> int:
self._buf += str(s)
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
self._handle_line(line)
return len(s)
def _handle_line(self, line: str) -> None:
# Start capturing tracebacks so we can suppress the whole block if it matches.
if not self._in_tb and line.startswith("Traceback (most recent call last):"):
self._in_tb = True
self._tb_lines = [line]
self._tb_suppress = False
return
if self._in_tb:
self._tb_lines.append(line)
if self._should_suppress_line(line):
self._tb_suppress = True
# End traceback block on blank line.
if line.strip() == "":
self._flush_tb()
return
# Non-traceback line
if self._should_suppress_line(line):
return
try:
self._underlying.write(line + "\n")
except Exception:
pass
def flush(self) -> None:
# Flush any pending traceback block.
if self._in_tb:
# If the traceback ends without a trailing blank line, decide here.
self._flush_tb()
if self._buf:
line = self._buf
self._buf = ""
if not self._should_suppress_line(line):
try:
self._underlying.write(line)
except Exception:
pass
try:
self._underlying.flush()
except Exception:
pass
@contextlib.contextmanager
def _suppress_aioslsk_noise() -> Any:
"""Temporarily suppress known aioslsk noise printed to stdout/stderr.
Opt out by setting DOWNLOW_SOULSEEK_VERBOSE=1.
"""
if os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
yield
return
_configure_aioslsk_logging()
old_out, old_err = sys.stdout, sys.stderr
sys.stdout = _LineFilterStream(old_out, _SOULSEEK_NOISE_SUBSTRINGS)
sys.stderr = _LineFilterStream(old_err, _SOULSEEK_NOISE_SUBSTRINGS)
try:
yield
finally:
try:
sys.stdout.flush()
sys.stderr.flush()
except Exception:
pass
sys.stdout, sys.stderr = old_out, old_err
class Soulseek(SearchProvider): class Soulseek(SearchProvider):
"""Search provider for Soulseek P2P network.""" """Search provider for Soulseek P2P network."""
@@ -90,7 +231,6 @@ class Soulseek(SearchProvider):
async def perform_search(self, query: str, timeout: float = 9.0, limit: int = 50) -> List[Dict[str, Any]]: async def perform_search(self, query: str, timeout: float = 9.0, limit: int = 50) -> List[Dict[str, Any]]:
"""Perform async Soulseek search.""" """Perform async Soulseek search."""
import os
from aioslsk.client import SoulSeekClient from aioslsk.client import SoulSeekClient
from aioslsk.settings import CredentialsSettings, Settings from aioslsk.settings import CredentialsSettings, Settings
@@ -99,6 +239,7 @@ class Soulseek(SearchProvider):
settings = Settings(credentials=CredentialsSettings(username=self.USERNAME, password=self.PASSWORD)) settings = Settings(credentials=CredentialsSettings(username=self.USERNAME, password=self.PASSWORD))
client = SoulSeekClient(settings) client = SoulSeekClient(settings)
with _suppress_aioslsk_noise():
try: try:
await client.start() await client.start()
await client.login() await client.login()
@@ -114,6 +255,17 @@ class Soulseek(SearchProvider):
log(f"[soulseek] Search error: {type(exc).__name__}: {exc}", file=sys.stderr) log(f"[soulseek] Search error: {type(exc).__name__}: {exc}", file=sys.stderr)
return [] return []
finally: finally:
# Best-effort: try to cancel/close the search request before stopping
# the client to reduce stray reply spam.
try:
if "search_request" in locals() and search_request is not None:
cancel = getattr(search_request, "cancel", None)
if callable(cancel):
maybe = cancel()
if asyncio.iscoroutine(maybe):
await maybe
except Exception:
pass
try: try:
await client.stop() await client.stop()
except Exception: except Exception:
@@ -322,6 +474,7 @@ async def download_soulseek_file(
settings = Settings(credentials=CredentialsSettings(username=Soulseek.USERNAME, password=Soulseek.PASSWORD)) settings = Settings(credentials=CredentialsSettings(username=Soulseek.USERNAME, password=Soulseek.PASSWORD))
client = SoulSeekClient(settings) client = SoulSeekClient(settings)
with _suppress_aioslsk_noise():
try: try:
await client.start() await client.start()
await client.login() await client.login()
+2 -2
View File
@@ -111,7 +111,7 @@ def create_metadata_sidecar(file_path: Path, metadata: dict) -> None:
raise RuntimeError(f"Failed to write metadata sidecar {metadata_path}: {exc}") from exc raise RuntimeError(f"Failed to write metadata sidecar {metadata_path}: {exc}") from exc
def create_tags_sidecar(file_path: Path, tags: set) -> None: def create_tags_sidecar(file_path: Path, tags: set) -> None:
"""Create a .tags sidecar file with tags (one per line). """Create a .tag sidecar file with tags (one per line).
Args: Args:
file_path: Path to the exported file file_path: Path to the exported file
@@ -120,7 +120,7 @@ def create_tags_sidecar(file_path: Path, tags: set) -> None:
if not tags: if not tags:
return return
tags_path = file_path.with_suffix(file_path.suffix + '.tags') tags_path = file_path.with_suffix(file_path.suffix + '.tag')
try: try:
with open(tags_path, 'w', encoding='utf-8') as f: with open(tags_path, 'w', encoding='utf-8') as f:
for tag in sorted(tags): for tag in sorted(tags):
+14 -17
View File
@@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
from SYS.logger import debug, log from SYS.logger import debug, log
from SYS.utils import sha256_file from SYS.utils import sha256_file
from Store._base import StoreBackend from Store._base import Store
def _normalize_hash(value: Any) -> Optional[str]: def _normalize_hash(value: Any) -> Optional[str]:
@@ -30,7 +30,7 @@ def _resolve_file_hash(db_hash: Optional[str], file_path: Path) -> Optional[str]
return _normalize_hash(file_path.stem) return _normalize_hash(file_path.stem)
class Folder(StoreBackend): class Folder(Store):
"""""" """"""
# Track which locations have already been migrated to avoid repeated migrations # Track which locations have already been migrated to avoid repeated migrations
_migrated_locations = set() _migrated_locations = set()
@@ -243,7 +243,7 @@ class Folder(StoreBackend):
Args: Args:
file_path: Path to the file to add file_path: Path to the file to add
move: If True, move file instead of copy (default: False) move: If True, move file instead of copy (default: False)
tags: Optional list of tags to add tag: Optional list of tag values to add
url: Optional list of url to associate with the file url: Optional list of url to associate with the file
title: Optional title (will be added as 'title:value' tag) title: Optional title (will be added as 'title:value' tag)
@@ -251,15 +251,15 @@ class Folder(StoreBackend):
File hash (SHA256 hex string) as identifier File hash (SHA256 hex string) as identifier
""" """
move_file = bool(kwargs.get("move")) move_file = bool(kwargs.get("move"))
tags = kwargs.get("tags", []) tag_list = kwargs.get("tag", [])
url = kwargs.get("url", []) url = kwargs.get("url", [])
title = kwargs.get("title") title = kwargs.get("title")
# Extract title from tags if not explicitly provided # Extract title from tags if not explicitly provided
if not title: if not title:
for tag in tags: for candidate in tag_list:
if isinstance(tag, str) and tag.lower().startswith("title:"): if isinstance(candidate, str) and candidate.lower().startswith("title:"):
title = tag.split(":", 1)[1].strip() title = candidate.split(":", 1)[1].strip()
break break
# Fallback to filename if no title # Fallback to filename if no title
@@ -268,8 +268,8 @@ class Folder(StoreBackend):
# Ensure title is in tags # Ensure title is in tags
title_tag = f"title:{title}" title_tag = f"title:{title}"
if not any(str(tag).lower().startswith("title:") for tag in tags): if not any(str(candidate).lower().startswith("title:") for candidate in tag_list):
tags = [title_tag] + list(tags) tag_list = [title_tag] + list(tag_list)
try: try:
file_hash = sha256_file(file_path) file_hash = sha256_file(file_path)
@@ -290,8 +290,8 @@ class Folder(StoreBackend):
file=sys.stderr, file=sys.stderr,
) )
# Still add tags and url if provided # Still add tags and url if provided
if tags: if tag_list:
self.add_tag(file_hash, tags) self.add_tag(file_hash, tag_list)
if url: if url:
self.add_url(file_hash, url) self.add_url(file_hash, url)
return file_hash return file_hash
@@ -316,8 +316,8 @@ class Folder(StoreBackend):
}) })
# Add tags if provided # Add tags if provided
if tags: if tag_list:
self.add_tag(file_hash, tags) self.add_tag(file_hash, tag_list)
# Add url if provided # Add url if provided
if url: if url:
@@ -330,7 +330,7 @@ class Folder(StoreBackend):
log(f"❌ Local storage failed: {exc}", file=sys.stderr) log(f"❌ Local storage failed: {exc}", file=sys.stderr)
raise raise
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]: def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Search local database for files by title tag or filename.""" """Search local database for files by title tag or filename."""
from fnmatch import fnmatch from fnmatch import fnmatch
from API.folder import DatabaseAPI from API.folder import DatabaseAPI
@@ -685,9 +685,6 @@ class Folder(StoreBackend):
log(f"❌ Local search failed: {exc}", file=sys.stderr) log(f"❌ Local search failed: {exc}", file=sys.stderr)
raise raise
def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Alias for search_file to match the interface expected by FileStorage."""
return self.search_store(query, **kwargs)
def _resolve_library_root(self, file_path: Path, config: Dict[str, Any]) -> Optional[Path]: def _resolve_library_root(self, file_path: Path, config: Dict[str, Any]) -> Optional[Path]:
"""Return the library root containing medios-macina.db. """Return the library root containing medios-macina.db.
+15 -15
View File
@@ -8,10 +8,10 @@ from typing import Any, Dict, List, Optional, Tuple
from SYS.logger import debug, log from SYS.logger import debug, log
from SYS.utils_constant import mime_maps from SYS.utils_constant import mime_maps
from Store._base import StoreBackend from Store._base import Store
class HydrusNetwork(StoreBackend): class HydrusNetwork(Store):
"""File storage backend for Hydrus client. """File storage backend for Hydrus client.
Each instance represents a specific Hydrus client connection. Each instance represents a specific Hydrus client connection.
@@ -26,7 +26,7 @@ class HydrusNetwork(StoreBackend):
api_key: Hydrus Client API access key api_key: Hydrus Client API access key
url: Hydrus client URL (e.g., 'http://192.168.1.230:45869') url: Hydrus client URL (e.g., 'http://192.168.1.230:45869')
""" """
from API.HydrusNetwork import HydrusClient from API.HydrusNetwork import HydrusNetwork as HydrusClient
self._instance_name = instance_name self._instance_name = instance_name
self._api_key = api_key self._api_key = api_key
@@ -45,7 +45,7 @@ class HydrusNetwork(StoreBackend):
Args: Args:
file_path: Path to the file to upload file_path: Path to the file to upload
tags: Optional list of tags to add tag: Optional list of tag values to add
url: Optional list of url to associate with the file url: Optional list of url to associate with the file
title: Optional title (will be added as 'title:value' tag) title: Optional title (will be added as 'title:value' tag)
@@ -57,15 +57,15 @@ class HydrusNetwork(StoreBackend):
""" """
from SYS.utils import sha256_file from SYS.utils import sha256_file
tags = kwargs.get("tags", []) tag_list = kwargs.get("tag", [])
url = kwargs.get("url", []) url = kwargs.get("url", [])
title = kwargs.get("title") title = kwargs.get("title")
# Add title to tags if provided and not already present # Add title to tags if provided and not already present
if title: if title:
title_tag = f"title:{title}" title_tag = f"title:{title}"
if not any(str(tag).lower().startswith("title:") for tag in tags): if not any(str(candidate).lower().startswith("title:") for candidate in tag_list):
tags = [title_tag] + list(tags) tag_list = [title_tag] + list(tag_list)
try: try:
# Compute file hash # Compute file hash
@@ -113,7 +113,7 @@ class HydrusNetwork(StoreBackend):
log(f"Hydrus: {file_hash}", file=sys.stderr) log(f"Hydrus: {file_hash}", file=sys.stderr)
# Add tags if provided (both for new and existing files) # Add tags if provided (both for new and existing files)
if tags: if tag_list:
try: try:
# Use default tag service # Use default tag service
service_name = "my tags" service_name = "my tags"
@@ -121,8 +121,8 @@ class HydrusNetwork(StoreBackend):
service_name = "my tags" service_name = "my tags"
try: try:
debug(f"Adding {len(tags)} tag(s) to Hydrus: {tags}") debug(f"Adding {len(tag_list)} tag(s) to Hydrus: {tag_list}")
client.add_tags(file_hash, tags, service_name) client.add_tag(file_hash, tag_list, service_name)
log(f"Tags added via '{service_name}'", file=sys.stderr) log(f"Tags added via '{service_name}'", file=sys.stderr)
except Exception as exc: except Exception as exc:
log(f"⚠️ Failed to add tags: {exc}", file=sys.stderr) log(f"⚠️ Failed to add tags: {exc}", file=sys.stderr)
@@ -144,7 +144,7 @@ class HydrusNetwork(StoreBackend):
log(f"❌ Hydrus upload failed: {exc}", file=sys.stderr) log(f"❌ Hydrus upload failed: {exc}", file=sys.stderr)
raise raise
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]: def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
"""Search Hydrus database for files matching query. """Search Hydrus database for files matching query.
Args: Args:
@@ -290,7 +290,7 @@ class HydrusNetwork(StoreBackend):
"size": size, "size": size,
"size_bytes": size, "size_bytes": size,
"store": self._instance_name, "store": self._instance_name,
"tags": all_tags, "tag": all_tags,
"file_id": file_id, "file_id": file_id,
"mime": mime_type, "mime": mime_type,
"ext": ext, "ext": ext,
@@ -323,7 +323,7 @@ class HydrusNetwork(StoreBackend):
"size": size, "size": size,
"size_bytes": size, "size_bytes": size,
"store": self._instance_name, "store": self._instance_name,
"tags": all_tags, "tag": all_tags,
"file_id": file_id, "file_id": file_id,
"mime": mime_type, "mime": mime_type,
"ext": ext, "ext": ext,
@@ -488,7 +488,7 @@ class HydrusNetwork(StoreBackend):
tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)] tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)]
if not tag_list: if not tag_list:
return False return False
client.add_tags(file_identifier, tag_list, service_name) client.add_tag(file_identifier, tag_list, service_name)
return True return True
except Exception as exc: except Exception as exc:
debug(f"Hydrus add_tag failed: {exc}") debug(f"Hydrus add_tag failed: {exc}")
@@ -506,7 +506,7 @@ class HydrusNetwork(StoreBackend):
tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)] tag_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)]
if not tag_list: if not tag_list:
return False return False
client.delete_tags(file_identifier, tag_list, service_name) client.delete_tag(file_identifier, tag_list, service_name)
return True return True
except Exception as exc: except Exception as exc:
debug(f"Hydrus delete_tag failed: {exc}") debug(f"Hydrus delete_tag failed: {exc}")
+2 -2
View File
@@ -1,7 +1,7 @@
from Store._base import StoreBackend from Store._base import Store as BaseStore
from Store.registry import Store from Store.registry import Store
__all__ = [ __all__ = [
"StoreBackend",
"Store", "Store",
"BaseStore",
] ]
+2 -2
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
class StoreBackend(ABC): class Store(ABC):
@abstractmethod @abstractmethod
def add_file(self, file_path: Path, **kwargs: Any) -> str: def add_file(self, file_path: Path, **kwargs: Any) -> str:
raise NotImplementedError raise NotImplementedError
@@ -19,7 +19,7 @@ class StoreBackend(ABC):
def name(self) -> str: def name(self) -> str:
raise NotImplementedError raise NotImplementedError
def search_store(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]: def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]:
raise NotImplementedError(f"{self.name()} backend does not support searching") raise NotImplementedError(f"{self.name()} backend does not support searching")
@abstractmethod @abstractmethod
+4 -4
View File
@@ -25,7 +25,7 @@ from typing import Any, Dict, Optional
from SYS.logger import debug from SYS.logger import debug
from Store._base import StoreBackend from Store._base import Store as BaseStore
from Store.Folder import Folder from Store.Folder import Folder
from Store.HydrusNetwork import HydrusNetwork from Store.HydrusNetwork import HydrusNetwork
@@ -34,7 +34,7 @@ class Store:
def __init__(self, config: Optional[Dict[str, Any]] = None, suppress_debug: bool = False) -> None: def __init__(self, config: Optional[Dict[str, Any]] = None, suppress_debug: bool = False) -> None:
self._config = config or {} self._config = config or {}
self._suppress_debug = suppress_debug self._suppress_debug = suppress_debug
self._backends: Dict[str, StoreBackend] = {} self._backends: Dict[str, BaseStore] = {}
self._load_backends() self._load_backends()
def _load_backends(self) -> None: def _load_backends(self) -> None:
@@ -86,11 +86,11 @@ class Store:
def list_searchable_backends(self) -> list[str]: def list_searchable_backends(self) -> list[str]:
searchable: list[str] = [] searchable: list[str] = []
for name, backend in self._backends.items(): for name, backend in self._backends.items():
if type(backend).search_store is not StoreBackend.search_store: if type(backend).search is not BaseStore.search:
searchable.append(name) searchable.append(name)
return sorted(searchable) return sorted(searchable)
def __getitem__(self, backend_name: str) -> StoreBackend: def __getitem__(self, backend_name: str) -> BaseStore:
if backend_name not in self._backends: if backend_name not in self._backends:
raise KeyError(f"Unknown store backend: {backend_name}. Available: {list(self._backends.keys())}") raise KeyError(f"Unknown store backend: {backend_name}. Available: {list(self._backends.keys())}")
return self._backends[backend_name] return self._backends[backend_name]
+2 -2
View File
@@ -30,12 +30,12 @@ PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset( PipelinePreset(
label="Download → Merge → Local", label="Download → Merge → Local",
description="Use download-data with playlist auto-selection, merge the pieces, tag, then import into local storage.", description="Use download-data with playlist auto-selection, merge the pieces, tag, then import into local storage.",
pipeline='download-data "<url>" | merge-file | add-tag | add-file -storage local', pipeline='download-data "<url>" | merge-file | add-tags -store local | add-file -storage local',
), ),
PipelinePreset( PipelinePreset(
label="Download → Hydrus", label="Download → Hydrus",
description="Fetch media, auto-tag, and push directly into Hydrus.", description="Fetch media, auto-tag, and push directly into Hydrus.",
pipeline='download-data "<url>" | merge-file | add-tag | add-file -storage hydrus', pipeline='download-data "<url>" | merge-file | add-tags -store hydrus | add-file -storage hydrus',
), ),
PipelinePreset( PipelinePreset(
label="Search Local Library", label="Search Local Library",
+16 -15
View File
@@ -781,9 +781,9 @@ class DownloadModal(ModalScreen):
# Stage 3: Add tags (now after merge, if merge happened) # Stage 3: Add tags (now after merge, if merge happened)
# If merge succeeded, result_obj now points to merged file # If merge succeeded, result_obj now points to merged file
if tags and (download_succeeded or not download_enabled): if tags and (download_succeeded or not download_enabled):
add_tags_cmdlet = get_cmdlet("add-tag") add_tags_cmdlet = get_cmdlet("add-tags")
if add_tags_cmdlet: if add_tags_cmdlet:
logger.info(f"Executing add-tag stage with {len(tags)} tags") logger.info(f"Executing add-tags stage with {len(tags)} tags")
logger.info(f" Tags: {tags}") logger.info(f" Tags: {tags}")
logger.info(f" Source: {source}") logger.info(f" Source: {source}")
logger.info(f" Result path: {result_obj.path}") logger.info(f" Result path: {result_obj.path}")
@@ -791,10 +791,10 @@ class DownloadModal(ModalScreen):
# Log step to worker # Log step to worker
if worker: if worker:
worker.log_step(f"Starting add-tag stage with {len(tags)} tags...") worker.log_step(f"Starting add-tags stage with {len(tags)} tags...")
# Build add-tag arguments: tag1 tag2 tag3 --source <source> # Build add-tags arguments. add-tags requires a store; for downloads, default to local sidecar tagging.
tag_args = [str(t) for t in tags] + ["--source", str(source)] tag_args = ["-store", "local"] + [str(t) for t in tags] + ["--source", str(source)]
logger.info(f" Tag args: {tag_args}") logger.info(f" Tag args: {tag_args}")
logger.info(f" Result object attributes: target={getattr(result_obj, 'target', 'MISSING')}, path={getattr(result_obj, 'path', 'MISSING')}, hash_hex={getattr(result_obj, 'hash_hex', 'MISSING')}") logger.info(f" Result object attributes: target={getattr(result_obj, 'target', 'MISSING')}, path={getattr(result_obj, 'path', 'MISSING')}, hash_hex={getattr(result_obj, 'hash_hex', 'MISSING')}")
@@ -814,12 +814,12 @@ class DownloadModal(ModalScreen):
# Log the tag output so it gets captured by WorkerLoggingHandler # Log the tag output so it gets captured by WorkerLoggingHandler
if stdout_text: if stdout_text:
logger.info(f"[add-tag output]\n{stdout_text}") logger.info(f"[add-tags output]\n{stdout_text}")
if stderr_text: if stderr_text:
logger.info(f"[add-tag stderr]\n{stderr_text}") logger.info(f"[add-tags stderr]\n{stderr_text}")
if returncode != 0: if returncode != 0:
logger.error(f"add-tag stage failed with code {returncode}") logger.error(f"add-tags stage failed with code {returncode}")
logger.error(f" stdout: {stdout_text}") logger.error(f" stdout: {stdout_text}")
logger.error(f" stderr: {stderr_text}") logger.error(f" stderr: {stderr_text}")
self.app.call_from_thread( self.app.call_from_thread(
@@ -833,16 +833,16 @@ class DownloadModal(ModalScreen):
return return
else: else:
if stdout_text: if stdout_text:
logger.debug(f"add-tag stdout: {stdout_text}") logger.debug(f"add-tags stdout: {stdout_text}")
if stderr_text: if stderr_text:
logger.debug(f"add-tag stderr: {stderr_text}") logger.debug(f"add-tags stderr: {stderr_text}")
logger.info("add-tag stage completed successfully") logger.info("add-tags stage completed successfully")
# Log step to worker # Log step to worker
if worker: if worker:
worker.log_step(f"Successfully added {len(tags)} tags") worker.log_step(f"Successfully added {len(tags)} tags")
except Exception as e: except Exception as e:
logger.error(f"add-tag execution error: {e}", exc_info=True) logger.error(f"add-tags execution error: {e}", exc_info=True)
self.app.call_from_thread( self.app.call_from_thread(
self.app.notify, self.app.notify,
f"Error adding tags: {e}", f"Error adding tags: {e}",
@@ -852,10 +852,10 @@ class DownloadModal(ModalScreen):
self.app.call_from_thread(self._hide_progress) self.app.call_from_thread(self._hide_progress)
return return
else: else:
logger.error("add-tag cmdlet not found") logger.error("add-tags cmdlet not found")
else: else:
if tags and download_enabled and not download_succeeded: if tags and download_enabled and not download_succeeded:
skip_msg = "⚠️ Skipping add-tag stage because download failed" skip_msg = "⚠️ Skipping add-tags stage because download failed"
logger.info(skip_msg) logger.info(skip_msg)
if worker: if worker:
worker.append_stdout(f"\n{skip_msg}\n") worker.append_stdout(f"\n{skip_msg}\n")
@@ -1249,8 +1249,9 @@ class DownloadModal(ModalScreen):
stdout_buf = io.StringIO() stdout_buf = io.StringIO()
stderr_buf = io.StringIO() stderr_buf = io.StringIO()
tag_args = ["-store", "local"] + [str(t) for t in tags]
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
tag_returncode = tag_cmdlet(result_obj, tags, self.config) tag_returncode = tag_cmdlet(result_obj, tag_args, self.config)
if tag_returncode != 0: if tag_returncode != 0:
logger.warning(f"Tag stage returned code {tag_returncode}") logger.warning(f"Tag stage returned code {tag_returncode}")
+2 -2
View File
@@ -171,14 +171,14 @@ class ExportModal(ModalScreen):
with Container(id="export-container"): with Container(id="export-container"):
yield Static("Export File with Metadata", id="export-title") yield Static("Export File with Metadata", id="export-title")
# Row 1: Three columns (Tags, Metadata, Export-To Options) # Row 1: Three columns (Tag, Metadata, Export-To Options)
self.tags_textarea = TextArea( self.tags_textarea = TextArea(
text=self._format_tags(), text=self._format_tags(),
id="tags-area", id="tags-area",
read_only=False, read_only=False,
) )
yield self.tags_textarea yield self.tags_textarea
self.tags_textarea.border_title = "Tags" self.tags_textarea.border_title = "Tag"
# Metadata display instead of files tree # Metadata display instead of files tree
self.metadata_display = Static( self.metadata_display = Static(
+1 -1
View File
@@ -83,7 +83,7 @@ class PipelineHubApp(App):
with Container(id="app-shell"): with Container(id="app-shell"):
with Horizontal(id="command-pane"): with Horizontal(id="command-pane"):
self.command_input = Input( self.command_input = Input(
placeholder='download-data "<url>" | merge-file | add-tag | add-file -storage local', placeholder='download-data "<url>" | merge-file | add-tags -store local | add-file -storage local',
id="pipeline-input", id="pipeline-input",
) )
yield self.command_input yield self.command_input
+1 -1
View File
@@ -14,7 +14,7 @@ def register(names: Iterable[str]):
"""Decorator to register a function under one or more command names. """Decorator to register a function under one or more command names.
Usage: Usage:
@register(["add-tag", "add-tags"]) @register(["add-tags"])
def _run(result, args, config) -> int: ... def _run(result, args, config) -> int: ...
""" """
def _wrap(fn: Cmdlet) -> Cmdlet: def _wrap(fn: Cmdlet) -> Cmdlet:
+51 -45
View File
@@ -1,7 +1,4 @@
"""Shared utilities for cmdlets and funacts. """
This module provides common utility functions for working with hashes, tags,
relationship data, and other frequently-needed operations.
""" """
from __future__ import annotations from __future__ import annotations
@@ -192,7 +189,7 @@ class SharedArgs:
DELETE_FLAG = CmdletArg( DELETE_FLAG = CmdletArg(
"delete", "delete",
type="flag", type="flag",
description="Delete the file and its .tags after successful operation." description="Delete the file and its .tag after successful operation."
) )
# Metadata arguments # Metadata arguments
@@ -1092,7 +1089,7 @@ def create_pipe_object_result(
hash_value: Optional[str] = None, hash_value: Optional[str] = None,
is_temp: bool = False, is_temp: bool = False,
parent_hash: Optional[str] = None, parent_hash: Optional[str] = None,
tags: Optional[List[str]] = None, tag: Optional[List[str]] = None,
**extra: Any **extra: Any
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a PipeObject-compatible result dict for pipeline chaining. """Create a PipeObject-compatible result dict for pipeline chaining.
@@ -1109,7 +1106,7 @@ def create_pipe_object_result(
hash_value: SHA-256 hash of file (for integrity) hash_value: SHA-256 hash of file (for integrity)
is_temp: If True, this is a temporary/intermediate artifact is_temp: If True, this is a temporary/intermediate artifact
parent_hash: Hash of the parent file in the chain (for provenance) parent_hash: Hash of the parent file in the chain (for provenance)
tags: List of tags to apply tag: List of tag values to apply
**extra: Additional fields **extra: Additional fields
Returns: Returns:
@@ -1130,8 +1127,8 @@ def create_pipe_object_result(
result['is_temp'] = True result['is_temp'] = True
if parent_hash: if parent_hash:
result['parent_hash'] = parent_hash result['parent_hash'] = parent_hash
if tags: if tag:
result['tags'] = tags result['tag'] = tag
# Canonical store field: use source for compatibility # Canonical store field: use source for compatibility
try: try:
@@ -1350,33 +1347,46 @@ def collapse_namespace_tags(tags: Optional[Iterable[Any]], namespace: str, prefe
return result return result
def extract_tags_from_result(result: Any) -> list[str]: def collapse_namespace_tag(tags: Optional[Iterable[Any]], namespace: str, prefer: str = "last") -> list[str]:
tags: list[str] = [] """Singular alias for collapse_namespace_tags.
Some cmdlets prefer the singular name; keep behavior centralized.
"""
return collapse_namespace_tags(tags, namespace, prefer=prefer)
def extract_tag_from_result(result: Any) -> list[str]:
tag: list[str] = []
if isinstance(result, models.PipeObject): if isinstance(result, models.PipeObject):
tags.extend(result.tags or []) tag.extend(result.tag or [])
tags.extend(result.extra.get('tags', [])) if isinstance(result.extra, dict):
elif hasattr(result, 'tags'): extra_tag = result.extra.get('tag')
# Handle objects with tags attribute (e.g. SearchResult) if isinstance(extra_tag, list):
val = getattr(result, 'tags') tag.extend(extra_tag)
elif isinstance(extra_tag, str):
tag.append(extra_tag)
elif hasattr(result, 'tag'):
# Handle objects with tag attribute (e.g. SearchResult)
val = getattr(result, 'tag')
if isinstance(val, (list, set, tuple)): if isinstance(val, (list, set, tuple)):
tags.extend(val) tag.extend(val)
elif isinstance(val, str): elif isinstance(val, str):
tags.append(val) tag.append(val)
if isinstance(result, dict): if isinstance(result, dict):
raw_tags = result.get('tags') raw_tag = result.get('tag')
if isinstance(raw_tags, list): if isinstance(raw_tag, list):
tags.extend(raw_tags) tag.extend(raw_tag)
elif isinstance(raw_tags, str): elif isinstance(raw_tag, str):
tags.append(raw_tags) tag.append(raw_tag)
extra = result.get('extra') extra = result.get('extra')
if isinstance(extra, dict): if isinstance(extra, dict):
extra_tags = extra.get('tags') extra_tag = extra.get('tag')
if isinstance(extra_tags, list): if isinstance(extra_tag, list):
tags.extend(extra_tags) tag.extend(extra_tag)
elif isinstance(extra_tags, str): elif isinstance(extra_tag, str):
tags.append(extra_tags) tag.append(extra_tag)
return merge_sequences(tags, case_sensitive=True) return merge_sequences(tag, case_sensitive=True)
def extract_title_from_result(result: Any) -> Optional[str]: def extract_title_from_result(result: Any) -> Optional[str]:
@@ -1469,7 +1479,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
debug(f" target={getattr(value, 'target', None)}") debug(f" target={getattr(value, 'target', None)}")
debug(f" hash={getattr(value, 'hash', None)}") debug(f" hash={getattr(value, 'hash', None)}")
debug(f" media_kind={getattr(value, 'media_kind', None)}") debug(f" media_kind={getattr(value, 'media_kind', None)}")
debug(f" tags={getattr(value, 'tags', None)}") debug(f" tag={getattr(value, 'tag', None)}")
debug(f" tag_summary={getattr(value, 'tag_summary', None)}") debug(f" tag_summary={getattr(value, 'tag_summary', None)}")
debug(f" size_bytes={getattr(value, 'size_bytes', None)}") debug(f" size_bytes={getattr(value, 'size_bytes', None)}")
debug(f" duration_seconds={getattr(value, 'duration_seconds', None)}") debug(f" duration_seconds={getattr(value, 'duration_seconds', None)}")
@@ -1483,7 +1493,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
return value return value
known_keys = { known_keys = {
"hash", "store", "tags", "title", "url", "source_url", "duration", "metadata", "hash", "store", "tag", "title", "url", "source_url", "duration", "metadata",
"warnings", "path", "relationships", "is_temp", "action", "parent_hash", "warnings", "path", "relationships", "is_temp", "action", "parent_hash",
} }
@@ -1542,18 +1552,14 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
# Extract relationships # Extract relationships
rels = value.get("relationships") or {} rels = value.get("relationships") or {}
# Consolidate tags: prefer tags_set over tags, tag_summary # Canonical tag: accept list or single string
tags_val = [] tag_val: list[str] = []
if "tags_set" in value and value["tags_set"]: if "tag" in value:
tags_val = list(value["tags_set"]) raw_tag = value["tag"]
elif "tags" in value and isinstance(value["tags"], (list, set)): if isinstance(raw_tag, list):
tags_val = list(value["tags"]) tag_val = [str(t) for t in raw_tag if t is not None]
elif "tag" in value: elif isinstance(raw_tag, str):
# Single tag string or list tag_val = [raw_tag]
if isinstance(value["tag"], list):
tags_val = value["tag"] # Already a list
else:
tags_val = [value["tag"]] # Wrap single string in list
# Consolidate path: prefer explicit path key, but NOT target if it's a URL # Consolidate path: prefer explicit path key, but NOT target if it's a URL
path_val = value.get("path") path_val = value.get("path")
@@ -1580,7 +1586,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
pipe_obj = models.PipeObject( pipe_obj = models.PipeObject(
hash=hash_val, hash=hash_val,
store=store_val, store=store_val,
tags=tags_val, tag=tag_val,
title=title_val, title=title_val,
url=url_val, url=url_val,
source_url=value.get("source_url"), source_url=value.get("source_url"),
@@ -1624,7 +1630,7 @@ def coerce_to_pipe_object(value: Any, default_path: Optional[str] = None) -> mod
store=store_val, store=store_val,
path=str(path_val) if path_val and path_val != "unknown" else None, path=str(path_val) if path_val and path_val != "unknown" else None,
title=title_val, title=title_val,
tags=[], tag=[],
extra={}, extra={},
) )
+11 -15
View File
@@ -12,10 +12,10 @@ from SYS.logger import log, debug
from Store import Store from Store import Store
from ._shared import ( from ._shared import (
Cmdlet, CmdletArg, parse_cmdlet_args, SharedArgs, Cmdlet, CmdletArg, parse_cmdlet_args, SharedArgs,
extract_tags_from_result, extract_title_from_result, extract_url_from_result, extract_tag_from_result, extract_title_from_result, extract_url_from_result,
merge_sequences, extract_relationships, extract_duration, coerce_to_pipe_object merge_sequences, extract_relationships, extract_duration, coerce_to_pipe_object
) )
from ._shared import collapse_namespace_tags from ._shared import collapse_namespace_tag
from API.folder import read_sidecar, find_sidecar, write_sidecar, API_folder_store from API.folder import read_sidecar, find_sidecar, write_sidecar, API_folder_store
from SYS.utils import sha256_file, unique_path from SYS.utils import sha256_file, unique_path
from metadata import write_metadata from metadata import write_metadata
@@ -419,14 +419,14 @@ class Add_File(Cmdlet):
hash_value: str, hash_value: str,
store: str, store: str,
path: Optional[str], path: Optional[str],
tags: List[str], tag: List[str],
title: Optional[str], title: Optional[str],
extra_updates: Optional[Dict[str, Any]] = None, extra_updates: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
pipe_obj.hash = hash_value pipe_obj.hash = hash_value
pipe_obj.store = store pipe_obj.store = store
pipe_obj.path = path pipe_obj.path = path
pipe_obj.tags = tags pipe_obj.tag = tag
if title: if title:
pipe_obj.title = title pipe_obj.title = title
if isinstance(pipe_obj.extra, dict): if isinstance(pipe_obj.extra, dict):
@@ -452,10 +452,10 @@ class Add_File(Cmdlet):
Prepare tags, url, and title for the file. Prepare tags, url, and title for the file.
Returns (tags, url, preferred_title, file_hash) Returns (tags, url, preferred_title, file_hash)
""" """
tags_from_result = list(pipe_obj.tags or []) tags_from_result = list(pipe_obj.tag or [])
if not tags_from_result: if not tags_from_result:
try: try:
tags_from_result = list(extract_tags_from_result(result) or []) tags_from_result = list(extract_tag_from_result(result) or [])
except Exception: except Exception:
tags_from_result = [] tags_from_result = []
@@ -488,7 +488,7 @@ class Add_File(Cmdlet):
return tag return tag
tags_from_result_no_title = [t for t in tags_from_result if not str(t).strip().lower().startswith("title:")] tags_from_result_no_title = [t for t in tags_from_result if not str(t).strip().lower().startswith("title:")]
sidecar_tags = collapse_namespace_tags([normalize_title_tag(t) for t in sidecar_tags], "title", prefer="last") sidecar_tags = collapse_namespace_tag([normalize_title_tag(t) for t in sidecar_tags], "title", prefer="last")
sidecar_tags_filtered = [t for t in sidecar_tags if not str(t).strip().lower().startswith("title:")] sidecar_tags_filtered = [t for t in sidecar_tags if not str(t).strip().lower().startswith("title:")]
merged_tags = merge_sequences(tags_from_result_no_title, sidecar_tags_filtered, case_sensitive=True) merged_tags = merge_sequences(tags_from_result_no_title, sidecar_tags_filtered, case_sensitive=True)
@@ -501,7 +501,7 @@ class Add_File(Cmdlet):
file_hash = Add_File._resolve_file_hash(result, media_path, pipe_obj, sidecar_hash) file_hash = Add_File._resolve_file_hash(result, media_path, pipe_obj, sidecar_hash)
# Persist back to PipeObject # Persist back to PipeObject
pipe_obj.tags = merged_tags pipe_obj.tag = merged_tags
if preferred_title and not pipe_obj.title: if preferred_title and not pipe_obj.title:
pipe_obj.title = preferred_title pipe_obj.title = preferred_title
if file_hash and not pipe_obj.hash: if file_hash and not pipe_obj.hash:
@@ -591,7 +591,7 @@ class Add_File(Cmdlet):
hash_value=f_hash or "unknown", hash_value=f_hash or "unknown",
store="local", store="local",
path=str(target_path), path=str(target_path),
tags=tags, tag=tags,
title=chosen_title, title=chosen_title,
extra_updates=extra_updates, extra_updates=extra_updates,
) )
@@ -729,7 +729,7 @@ class Add_File(Cmdlet):
hash_value=f_hash or "unknown", hash_value=f_hash or "unknown",
store=provider_name or "provider", store=provider_name or "provider",
path=file_path, path=file_path,
tags=pipe_obj.tags, tag=pipe_obj.tag,
title=pipe_obj.title or (media_path.name if media_path else None), title=pipe_obj.title or (media_path.name if media_path else None),
extra_updates=extra_updates, extra_updates=extra_updates,
) )
@@ -782,7 +782,7 @@ class Add_File(Cmdlet):
hash_value=file_identifier if len(file_identifier) == 64 else f_hash or "unknown", hash_value=file_identifier if len(file_identifier) == 64 else f_hash or "unknown",
store=backend_name, store=backend_name,
path=stored_path, path=stored_path,
tags=tags, tag=tags,
title=title or pipe_obj.title or media_path.name, title=title or pipe_obj.title or media_path.name,
extra_updates={ extra_updates={
"url": url, "url": url,
@@ -907,8 +907,6 @@ class Add_File(Cmdlet):
possible_sidecars = [ possible_sidecars = [
source_path.with_suffix(source_path.suffix + ".json"), source_path.with_suffix(source_path.suffix + ".json"),
source_path.with_name(source_path.name + ".tag"), source_path.with_name(source_path.name + ".tag"),
source_path.with_name(source_path.name + ".tags"),
source_path.with_name(source_path.name + ".tags.txt"),
source_path.with_name(source_path.name + ".metadata"), source_path.with_name(source_path.name + ".metadata"),
source_path.with_name(source_path.name + ".notes"), source_path.with_name(source_path.name + ".notes"),
] ]
@@ -944,8 +942,6 @@ class Add_File(Cmdlet):
media_path.parent / (media_path.name + '.metadata'), media_path.parent / (media_path.name + '.metadata'),
media_path.parent / (media_path.name + '.notes'), media_path.parent / (media_path.name + '.notes'),
media_path.parent / (media_path.name + '.tag'), media_path.parent / (media_path.name + '.tag'),
media_path.parent / (media_path.name + '.tags'),
media_path.parent / (media_path.name + '.tags.txt'),
] ]
for target in targets: for target in targets:
try: try:
+256 -291
View File
@@ -9,68 +9,43 @@ from SYS.logger import log
import models import models
import pipeline as ctx import pipeline as ctx
from ._shared import normalize_result_input, filter_results_by_temp from ._shared import normalize_result_input, filter_results_by_temp
from API import HydrusNetwork as hydrus_wrapper from ._shared import (
from API.folder import write_sidecar, API_folder_store Cmdlet,
from ._shared import Cmdlet, CmdletArg, SharedArgs, normalize_hash, parse_tag_arguments, expand_tag_groups, parse_cmdlet_args, collapse_namespace_tags, should_show_help, get_field CmdletArg,
from config import get_local_storage_path SharedArgs,
normalize_hash,
parse_tag_arguments,
expand_tag_groups,
class Add_Tag(Cmdlet): parse_cmdlet_args,
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance.""" collapse_namespace_tag,
should_show_help,
def __init__(self) -> None: get_field,
super().__init__(
name="add-tag",
summary="Add a tag to a Hydrus file or write it to a local .tags sidecar.",
usage="add-tag [-hash <sha256>] [-store <backend>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
arg=[
SharedArgs.HASH,
SharedArgs.STORE,
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
],
detail=[
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
"- Without -hash and when the selection is a local file, tags are written to <file>.tags.",
"- With a Hydrus hash, tags are sent to the 'my tags' service.",
"- Multiple tags can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- Tags can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
],
exec=self.run,
) )
self.register() from Store import Store
from SYS.utils import sha256_file
@staticmethod
def _extract_title_tag(tags: List[str]) -> Optional[str]: def _extract_title_tag(tags: List[str]) -> Optional[str]:
"""Return the value of the first title: tag if present.""" """Return the value of the first title: tag if present."""
for tag in tags: for t in tags:
if isinstance(tag, str) and tag.lower().startswith("title:"): if t.lower().startswith("title:"):
value = tag.split(":", 1)[1].strip() value = t.split(":", 1)[1].strip()
if value: return value or None
return value
return None return None
@staticmethod
def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None: def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
"""Update result object/dict title fields and columns in-place.""" """Update result object/dict title fields and columns in-place."""
if not title_value: if not title_value:
return return
if isinstance(res, models.PipeObject): if isinstance(res, models.PipeObject):
res.title = title_value res.title = title_value
if hasattr(res, "columns") and isinstance(res.columns, list) and res.columns: # Update columns if present (Title column assumed index 0)
label, *_ = res.columns[0] columns = getattr(res, "columns", None)
if isinstance(columns, list) and columns:
label, *_ = columns[0]
if str(label).lower() == "title": if str(label).lower() == "title":
res.columns[0] = (res.columns[0][0], title_value) columns[0] = (label, title_value)
elif isinstance(res, dict): elif isinstance(res, dict):
res["title"] = title_value res["title"] = title_value
cols = res.get("columns") cols = res.get("columns")
@@ -79,7 +54,7 @@ class Add_Tag(Cmdlet):
changed = False changed = False
for col in cols: for col in cols:
if isinstance(col, tuple) and len(col) == 2: if isinstance(col, tuple) and len(col) == 2:
label, val = col label, _val = col
if str(label).lower() == "title": if str(label).lower() == "title":
updated.append((label, title_value)) updated.append((label, title_value))
changed = True changed = True
@@ -90,40 +65,39 @@ class Add_Tag(Cmdlet):
if changed: if changed:
res["columns"] = updated res["columns"] = updated
@staticmethod
def _matches_target(item: Any, file_hash: Optional[str], path: Optional[str]) -> bool: def _matches_target(item: Any, target_hash: Optional[str], target_path: Optional[str]) -> bool:
"""Determine whether a result item refers to the given hash/path target.""" """Determine whether a result item refers to the given hash/path target (canonical fields only)."""
file_hash_l = file_hash.lower() if file_hash else None
path_l = path.lower() if path else None
def norm(val: Any) -> Optional[str]: def norm(val: Any) -> Optional[str]:
return str(val).lower() if val is not None else None return str(val).lower() if val is not None else None
hash_fields = ["hash"] target_hash_l = target_hash.lower() if target_hash else None
path_fields = ["path", "target"] target_path_l = target_path.lower() if target_path else None
if isinstance(item, dict): if isinstance(item, dict):
hashes = [norm(item.get(field)) for field in hash_fields] hashes = [norm(item.get("hash"))]
paths = [norm(item.get(field)) for field in path_fields] paths = [norm(item.get("path"))]
else: else:
hashes = [norm(get_field(item, field)) for field in hash_fields] hashes = [norm(get_field(item, "hash"))]
paths = [norm(get_field(item, field)) for field in path_fields] paths = [norm(get_field(item, "path"))]
if file_hash_l and file_hash_l in hashes: if target_hash_l and target_hash_l in hashes:
return True return True
if path_l and path_l in paths: if target_path_l and target_path_l in paths:
return True return True
return False return False
@staticmethod
def _update_item_title_fields(item: Any, new_title: str) -> None: def _update_item_title_fields(item: Any, new_title: str) -> None:
"""Mutate an item to reflect a new title in plain fields and columns.""" """Mutate an item to reflect a new title in plain fields and columns."""
if isinstance(item, models.PipeObject): if isinstance(item, models.PipeObject):
item.title = new_title item.title = new_title
if hasattr(item, "columns") and isinstance(item.columns, list) and item.columns: columns = getattr(item, "columns", None)
label, *_ = item.columns[0] if isinstance(columns, list) and columns:
label, *_ = columns[0]
if str(label).lower() == "title": if str(label).lower() == "title":
item.columns[0] = (label, new_title) columns[0] = (label, new_title)
elif isinstance(item, dict): elif isinstance(item, dict):
item["title"] = new_title item["title"] = new_title
cols = item.get("columns") cols = item.get("columns")
@@ -132,7 +106,7 @@ class Add_Tag(Cmdlet):
changed = False changed = False
for col in cols: for col in cols:
if isinstance(col, tuple) and len(col) == 2: if isinstance(col, tuple) and len(col) == 2:
label, val = col label, _val = col
if str(label).lower() == "title": if str(label).lower() == "title":
updated_cols.append((label, new_title)) updated_cols.append((label, new_title))
changed = True changed = True
@@ -143,7 +117,8 @@ class Add_Tag(Cmdlet):
if changed: if changed:
item["columns"] = updated_cols item["columns"] = updated_cols
def _refresh_result_table_title(self, new_title: str, file_hash: Optional[str], path: Optional[str]) -> None:
def _refresh_result_table_title(new_title: str, target_hash: Optional[str], target_path: Optional[str]) -> None:
"""Refresh the cached result table with an updated title and redisplay it.""" """Refresh the cached result table with an updated title and redisplay it."""
try: try:
last_table = ctx.get_last_result_table() last_table = ctx.get_last_result_table()
@@ -155,8 +130,8 @@ class Add_Tag(Cmdlet):
match_found = False match_found = False
for item in items: for item in items:
try: try:
if self._matches_target(item, file_hash, path): if _matches_target(item, target_hash, target_path):
self._update_item_title_fields(item, new_title) _update_item_title_fields(item, new_title)
match_found = True match_found = True
except Exception: except Exception:
pass pass
@@ -164,67 +139,94 @@ class Add_Tag(Cmdlet):
if not match_found: if not match_found:
return return
from result_table import ResultTable # Local import to avoid circular dependency
new_table = last_table.copy_with_title(getattr(last_table, "title", "")) new_table = last_table.copy_with_title(getattr(last_table, "title", ""))
for item in updated_items: for item in updated_items:
new_table.add_result(item) new_table.add_result(item)
# Keep the underlying history intact; update only the overlay so @.. can
# clear the overlay then continue back to prior tables (e.g., the search list).
ctx.set_last_result_table_overlay(new_table, updated_items) ctx.set_last_result_table_overlay(new_table, updated_items)
except Exception: except Exception:
pass pass
def _refresh_tags_view(self, res: Any, file_hash: Optional[str], path: Optional[str], config: Dict[str, Any]) -> None:
def _refresh_tag_view(res: Any, target_hash: Optional[str], store_name: Optional[str], target_path: Optional[str], config: Dict[str, Any]) -> None:
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh.""" """Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
try: try:
from cmdlets import get_tag as get_tag_cmd # type: ignore from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception: except Exception:
return return
target_hash = file_hash if not target_hash or not store_name:
refresh_args: List[str] = [] return
if target_hash:
refresh_args = ["-hash", target_hash] refresh_args: List[str] = ["-hash", target_hash, "-store", store_name]
try: try:
subject = ctx.get_last_result_subject() subject = ctx.get_last_result_subject()
if subject and self._matches_target(subject, file_hash, path): if subject and _matches_target(subject, target_hash, target_path):
get_tag_cmd._run(subject, refresh_args, config) get_tag_cmd._run(subject, refresh_args, config)
return return
except Exception: except Exception:
pass pass
if target_hash:
try: try:
get_tag_cmd._run(res, refresh_args, config) get_tag_cmd._run(res, refresh_args, config)
except Exception: except Exception:
pass pass
class Add_Tag(Cmdlet):
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
def __init__(self) -> None:
super().__init__(
name="add-tag",
summary="Add tag to a file in a store.",
usage="add-tag -store <store> [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
arg=[
SharedArgs.HASH,
SharedArgs.STORE,
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tag non-temporary files)."),
CmdletArg("tag", type="string", required=False, description="One or more tag to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tag from pipeline payload.", variadic=True),
],
detail=[
"- By default, only tag non-temporary files (from pipelines). Use --all to tag everything.",
"- Requires a store backend: use -store or pipe items that include store.",
"- If -hash is not provided, uses the piped item's hash (or derives from its path when possible).",
"- Multiple tag can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- tag can also reference lists with curly braces: add-tag {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
],
exec=self.run,
)
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Add a tag to a file with smart filtering for pipeline results.""" """Add tag to a file with smart filtering for pipeline results."""
if should_show_help(args): if should_show_help(args):
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}") log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
return 0 return 0
# Parse arguments
parsed = parse_cmdlet_args(args, self) parsed = parse_cmdlet_args(args, self)
# Check for --all flag # Check for --all flag
include_temp = parsed.get("all", False) include_temp = parsed.get("all", False)
# Get explicit -hash and -store overrides from CLI
hash_override = normalize_hash(parsed.get("hash"))
store_override = parsed.get("store")
# Normalize input to list # Normalize input to list
results = normalize_result_input(result) results = normalize_result_input(result)
# If no piped results but we have -hash flag, create a minimal synthetic result
if not results and hash_override:
results = [{"hash": hash_override, "is_temp": False}]
if store_override:
results[0]["store"] = store_override
# Filter by temp status (unless --all is set) # Filter by temp status (unless --all is set)
if not include_temp: if not include_temp:
results = filter_results_by_temp(results, include_temp=False) results = filter_results_by_temp(results, include_temp=False)
@@ -233,34 +235,35 @@ class Add_Tag(Cmdlet):
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr) log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
return 1 return 1
# Get tags from arguments (or fallback to pipeline payload) # Get tag from arguments (or fallback to pipeline payload)
raw_tags = parsed.get("tags", []) raw_tag = parsed.get("tag", [])
if isinstance(raw_tags, str): if isinstance(raw_tag, str):
raw_tags = [raw_tags] raw_tag = [raw_tag]
# Fallback: if no tags provided explicitly, try to pull from first result payload # Fallback: if no tag provided explicitly, try to pull from first result payload
if not raw_tags and results: if not raw_tag and results:
first = results[0] first = results[0]
payload_tags = None payload_tag = None
# Try multiple tag lookup strategies in order # Try multiple tag lookup strategies in order
tag_lookups = [ tag_lookups = [
lambda x: x.extra.get("tags") if isinstance(x, models.PipeObject) and isinstance(x.extra, dict) else None, lambda x: getattr(x, "tag", None),
lambda x: x.get("tags") if isinstance(x, dict) else None, lambda x: x.get("tag") if isinstance(x, dict) else None,
lambda x: x.get("extra", {}).get("tags") if isinstance(x, dict) and isinstance(x.get("extra"), dict) else None,
lambda x: getattr(x, "tags", None),
] ]
for lookup in tag_lookups: for lookup in tag_lookups:
try: try:
payload_tags = lookup(first) payload_tag = lookup(first)
if payload_tags: if payload_tag:
break break
except (AttributeError, TypeError, KeyError): except (AttributeError, TypeError, KeyError):
continue continue
if payload_tags:
if isinstance(payload_tags, str): if payload_tag:
raw_tags = [payload_tags] if isinstance(payload_tag, str):
elif isinstance(payload_tags, list): raw_tag = [payload_tag]
raw_tags = payload_tags elif isinstance(payload_tag, list):
raw_tag = payload_tag
# Handle -list argument (convert to {list} syntax) # Handle -list argument (convert to {list} syntax)
list_arg = parsed.get("list") list_arg = parsed.get("list")
@@ -268,222 +271,184 @@ class Add_Tag(Cmdlet):
for l in list_arg.split(','): for l in list_arg.split(','):
l = l.strip() l = l.strip()
if l: if l:
raw_tags.append(f"{{{l}}}") raw_tag.append(f"{{{l}}}")
# Parse and expand tags # Parse and expand tag
tags_to_add = parse_tag_arguments(raw_tags) tag_to_add = parse_tag_arguments(raw_tag)
tags_to_add = expand_tag_groups(tags_to_add) tag_to_add = expand_tag_groups(tag_to_add)
# Allow hash override via namespaced token (e.g., "hash:abcdef...") # Allow hash override via namespaced token (e.g., "hash:abcdef...")
extracted_hash = None extracted_hash = None
filtered_tags: List[str] = [] filtered_tag: List[str] = []
for tag in tags_to_add: for tag in tag_to_add:
if isinstance(tag, str) and tag.lower().startswith("hash:"): if isinstance(tag, str) and tag.lower().startswith("hash:"):
_, _, hash_val = tag.partition(":") _, _, hash_val = tag.partition(":")
if hash_val: if hash_val:
extracted_hash = normalize_hash(hash_val.strip()) extracted_hash = normalize_hash(hash_val.strip())
continue continue
filtered_tags.append(tag) filtered_tag.append(tag)
tags_to_add = filtered_tags tag_to_add = filtered_tag
if not tags_to_add: if not tag_to_add:
log("No tags provided to add", file=sys.stderr) log("No tag provided to add", file=sys.stderr)
return 1 return 1
def _find_library_root(path_obj: Path) -> Optional[Path]: # Get other flags (hash override can come from -hash or hash: token)
candidates = [] hash_override = normalize_hash(parsed.get("hash")) or extracted_hash
cfg_root = get_local_storage_path(config) if config else None
if cfg_root:
try:
candidates.append(Path(cfg_root).expanduser())
except Exception:
pass
try:
for candidate in candidates:
if (candidate / "medios-macina.db").exists():
return candidate
for parent in [path_obj] + list(path_obj.parents):
if (parent / "medios-macina.db").exists():
return parent
except Exception:
pass
return None
# Get other flags
duplicate_arg = parsed.get("duplicate") duplicate_arg = parsed.get("duplicate")
if not tags_to_add and not duplicate_arg: # tag ARE provided - apply them to each store-backed result
# Write sidecar files with the tags that are already in the result dicts total_added = 0
sidecar_count = 0
for res in results:
# Handle both dict and PipeObject formats
file_path = None
tags = []
file_hash = ""
# Use canonical field access with get_field for both dict and objects
file_path = get_field(res, "path")
# Try tags from top-level 'tags' or from 'extra.tags'
tags = get_field(res, "tags") or (get_field(res, "extra") or {}).get("tags", [])
file_hash = get_field(res, "hash") or ""
if not file_path:
log(f"[add_tag] Warning: Result has no path, skipping", file=sys.stderr)
ctx.emit(res)
continue
if tags:
# Write sidecar file for this file with its tags
try:
sidecar_path = write_sidecar(Path(file_path), tags, [], file_hash)
log(f"[add_tag] Wrote {len(tags)} tag(s) to sidecar: {sidecar_path}", file=sys.stderr)
sidecar_count += 1
except Exception as e:
log(f"[add_tag] Warning: Failed to write sidecar for {file_path}: {e}", file=sys.stderr)
ctx.emit(res)
if sidecar_count > 0:
log(f"[add_tag] Wrote {sidecar_count} sidecar file(s) with embedded tags", file=sys.stderr)
else:
log(f"[add_tag] No tags to write - passed {len(results)} result(s) through unchanged", file=sys.stderr)
return 0
# Main loop: process results with tags to add
total_new_tags = 0
total_modified = 0 total_modified = 0
store_override = parsed.get("store")
for res in results: for res in results:
# Extract file info from result store_name: Optional[str]
file_path = None raw_hash: Optional[str]
existing_tags = [] raw_path: Optional[str]
file_hash = ""
storage_source = None
# Use canonical getters for fields from both dicts and PipeObject if isinstance(res, models.PipeObject):
file_path = get_field(res, "path") store_name = store_override or res.store
existing_tags = get_field(res, "tags") or [] raw_hash = res.hash
if not existing_tags: raw_path = res.path
existing_tags = (get_field(res, "extra", {}) or {}).get("tags") or [] elif isinstance(res, dict):
file_hash = get_field(res, "hash") or "" store_name = store_override or res.get("store")
store_name = store_override or get_field(res, "store") raw_hash = res.get("hash")
raw_path = res.get("path")
original_tags_lower = {str(t).lower() for t in existing_tags if isinstance(t, str)}
original_title = self._extract_title_tag(list(existing_tags))
# Apply CLI overrides if provided
if hash_override and not file_hash:
file_hash = hash_override
if not store_name:
log("[add_tag] Missing store (use -store or pipe a result with store)", file=sys.stderr)
ctx.emit(res)
continue
# Check if we have sufficient identifier (file_path OR file_hash)
if not file_path and not file_hash:
log(f"[add_tag] Warning: Result has neither path nor hash available, skipping", file=sys.stderr)
ctx.emit(res)
continue
# Handle -duplicate logic (copy existing tags to new namespaces)
if duplicate_arg:
# Parse duplicate format: source:target1,target2 or source,target1,target2
parts = duplicate_arg.split(':')
source_ns = ""
targets = []
if len(parts) > 1:
# Explicit format: source:target1,target2
source_ns = parts[0]
targets = parts[1].split(',')
else: else:
# Inferred format: source,target1,target2 ctx.emit(res)
parts = duplicate_arg.split(',') continue
if not store_name:
log("[add_tag] Error: Missing -store and item has no store field", file=sys.stderr)
return 1
resolved_hash = normalize_hash(hash_override) if hash_override else normalize_hash(raw_hash)
if not resolved_hash and raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
resolved_hash = stem.lower()
elif p.exists() and p.is_file():
resolved_hash = sha256_file(p)
except Exception:
resolved_hash = None
if not resolved_hash:
log("[add_tag] Warning: Item missing usable hash (and could not derive from path); skipping", file=sys.stderr)
ctx.emit(res)
continue
try:
backend = Store(config)[str(store_name)]
except Exception as exc:
log(f"[add_tag] Error: Unknown store '{store_name}': {exc}", file=sys.stderr)
return 1
try:
existing_tag, _src = backend.get_tag(resolved_hash, config=config)
except Exception:
existing_tag = []
existing_tag_list = [t for t in (existing_tag or []) if isinstance(t, str)]
existing_lower = {t.lower() for t in existing_tag_list}
original_title = _extract_title_tag(existing_tag_list)
# Per-item tag list (do not mutate shared list)
item_tag_to_add = list(tag_to_add)
item_tag_to_add = collapse_namespace_tag(item_tag_to_add, "title", prefer="last")
# Handle -duplicate logic (copy existing tag to new namespaces)
if duplicate_arg:
parts = str(duplicate_arg).split(':')
source_ns = ""
targets: list[str] = []
if len(parts) > 1: if len(parts) > 1:
source_ns = parts[0] source_ns = parts[0]
targets = parts[1:] targets = [t.strip() for t in parts[1].split(',') if t.strip()]
else:
parts2 = str(duplicate_arg).split(',')
if len(parts2) > 1:
source_ns = parts2[0]
targets = [t.strip() for t in parts2[1:] if t.strip()]
if source_ns and targets: if source_ns and targets:
# Find tags in source namespace source_prefix = source_ns.lower() + ":"
source_tags = [t for t in existing_tags if t.startswith(source_ns + ':')] for t in existing_tag_list:
for t in source_tags: if not t.lower().startswith(source_prefix):
value = t.split(':', 1)[1] continue
value = t.split(":", 1)[1]
for target_ns in targets: for target_ns in targets:
new_tag = f"{target_ns}:{value}" new_tag = f"{target_ns}:{value}"
if new_tag not in existing_tags and new_tag not in tags_to_add: if new_tag.lower() not in existing_lower:
tags_to_add.append(new_tag) item_tag_to_add.append(new_tag)
# Initialize tag mutation tracking local variables # Namespace replacement: delete old namespace:* when adding namespace:value
removed_tags = [] removed_namespace_tag: list[str] = []
new_tags_added = [] for new_tag in item_tag_to_add:
final_tags = list(existing_tags) if existing_tags else [] if not isinstance(new_tag, str) or ":" not in new_tag:
# Resolve hash from path if needed
if not file_hash and file_path:
try:
from SYS.utils import sha256_file
file_hash = sha256_file(Path(file_path))
except Exception:
file_hash = ""
if not file_hash:
log("[add_tag] Warning: No hash available, skipping", file=sys.stderr)
ctx.emit(res)
continue continue
ns = new_tag.split(":", 1)[0].strip()
# Route tag updates through the configured store backend if not ns:
try:
storage = Store(config)
backend = storage[store_name]
# For namespaced tags, compute old tags in same namespace to remove
removed_tags = []
for new_tag in tags_to_add:
if ':' in new_tag:
namespace = new_tag.split(':', 1)[0]
to_remove = [t for t in existing_tags if t.startswith(namespace + ':') and t.lower() != new_tag.lower()]
removed_tags.extend(to_remove)
ok = backend.add_tag(file_hash, tags_to_add, config=config)
if removed_tags:
unique_removed = sorted(set(removed_tags))
backend.delete_tag(file_hash, unique_removed, config=config)
if not ok:
log(f"[add_tag] Warning: Failed to add tags via store '{store_name}'", file=sys.stderr)
ctx.emit(res)
continue continue
ns_prefix = ns.lower() + ":"
for t in existing_tag_list:
if t.lower().startswith(ns_prefix) and t.lower() != new_tag.lower():
removed_namespace_tag.append(t)
refreshed_tags, _ = backend.get_tag(file_hash, config=config) removed_namespace_tag = sorted({t for t in removed_namespace_tag})
refreshed_tags = list(refreshed_tags or [])
final_tags = refreshed_tags
new_tags_added = [t for t in refreshed_tags if t.lower() not in original_tags_lower]
# Update result tags for downstream cmdlets/UI actual_tag_to_add = [t for t in item_tag_to_add if isinstance(t, str) and t.lower() not in existing_lower]
if isinstance(res, models.PipeObject):
res.tags = refreshed_tags
if isinstance(res.extra, dict):
res.extra['tags'] = refreshed_tags
elif isinstance(res, dict):
res['tags'] = refreshed_tags
# Update title if changed changed = False
title_value = self._extract_title_tag(refreshed_tags) if removed_namespace_tag:
self._apply_title_to_result(res, title_value) try:
backend.delete_tag(resolved_hash, removed_namespace_tag, config=config)
changed = True
except Exception as exc:
log(f"[add_tag] Warning: Failed deleting namespace tag: {exc}", file=sys.stderr)
total_new_tags += len(new_tags_added) if actual_tag_to_add:
if new_tags_added: try:
backend.add_tag(resolved_hash, actual_tag_to_add, config=config)
changed = True
except Exception as exc:
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
if changed:
total_added += len(actual_tag_to_add)
total_modified += 1 total_modified += 1
except KeyError:
log(f"[add_tag] Store '{store_name}' not configured", file=sys.stderr)
ctx.emit(res)
continue
except Exception as e:
log(f"[add_tag] Warning: Backend error for store '{store_name}': {e}", file=sys.stderr)
ctx.emit(res)
continue
# If title changed, refresh the cached result table so the display reflects the new name try:
final_title = self._extract_title_tag(final_tags) refreshed_tag, _src2 = backend.get_tag(resolved_hash, config=config)
refreshed_list = [t for t in (refreshed_tag or []) if isinstance(t, str)]
except Exception:
refreshed_list = existing_tag_list
# Update the result's tag using canonical field
if isinstance(res, models.PipeObject):
res.tag = refreshed_list
elif isinstance(res, dict):
res["tag"] = refreshed_list
final_title = _extract_title_tag(refreshed_list)
_apply_title_to_result(res, final_title)
if final_title and (not original_title or final_title.lower() != original_title.lower()): if final_title and (not original_title or final_title.lower() != original_title.lower()):
self._refresh_result_table_title(final_title, file_hash, file_path) _refresh_result_table_title(final_title, resolved_hash, raw_path)
# If tags changed, refresh tag view via get-tag
if new_tags_added or removed_tags: if changed:
self._refresh_tags_view(res, file_hash, file_path, config) _refresh_tag_view(res, resolved_hash, str(store_name), raw_path, config)
# Emit the modified result
ctx.emit(res) ctx.emit(res)
log(f"[add_tag] Added {total_new_tags} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)", file=sys.stderr)
log(
f"[add_tag] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
file=sys.stderr,
)
return 0 return 0
-456
View File
@@ -1,456 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List, Sequence, Optional
from pathlib import Path
import sys
from SYS.logger import log
import models
import pipeline as ctx
from ._shared import normalize_result_input, filter_results_by_temp
from ._shared import (
Cmdlet,
CmdletArg,
SharedArgs,
normalize_hash,
parse_tag_arguments,
expand_tag_groups,
parse_cmdlet_args,
collapse_namespace_tags,
should_show_help,
get_field,
)
from Store import Store
from SYS.utils import sha256_file
def _extract_title_tag(tags: List[str]) -> Optional[str]:
"""Return the value of the first title: tag if present."""
for tag in tags:
if isinstance(tag, str) and tag.lower().startswith("title:"):
value = tag.split(":", 1)[1].strip()
if value:
return value
return None
def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
"""Update result object/dict title fields and columns in-place."""
if not title_value:
return
if isinstance(res, models.PipeObject):
res.title = title_value
# Update columns if present (Title column assumed index 0)
if hasattr(res, "columns") and isinstance(res.columns, list) and res.columns:
label, *_ = res.columns[0]
if str(label).lower() == "title":
res.columns[0] = (res.columns[0][0], title_value)
elif isinstance(res, dict):
res["title"] = title_value
cols = res.get("columns")
if isinstance(cols, list):
updated = []
changed = False
for col in cols:
if isinstance(col, tuple) and len(col) == 2:
label, val = col
if str(label).lower() == "title":
updated.append((label, title_value))
changed = True
else:
updated.append(col)
else:
updated.append(col)
if changed:
res["columns"] = updated
def _matches_target(item: Any, target_hash: Optional[str], target_path: Optional[str]) -> bool:
"""Determine whether a result item refers to the given hash/path target (canonical fields only)."""
def norm(val: Any) -> Optional[str]:
return str(val).lower() if val is not None else None
target_hash_l = target_hash.lower() if target_hash else None
target_path_l = target_path.lower() if target_path else None
if isinstance(item, dict):
hashes = [norm(item.get("hash"))]
paths = [norm(item.get("path"))]
else:
hashes = [norm(get_field(item, "hash"))]
paths = [norm(get_field(item, "path"))]
if target_hash_l and target_hash_l in hashes:
return True
if target_path_l and target_path_l in paths:
return True
return False
def _update_item_title_fields(item: Any, new_title: str) -> None:
"""Mutate an item to reflect a new title in plain fields and columns."""
if isinstance(item, models.PipeObject):
item.title = new_title
if hasattr(item, "columns") and isinstance(item.columns, list) and item.columns:
label, *_ = item.columns[0]
if str(label).lower() == "title":
item.columns[0] = (label, new_title)
elif isinstance(item, dict):
item["title"] = new_title
cols = item.get("columns")
if isinstance(cols, list):
updated_cols = []
changed = False
for col in cols:
if isinstance(col, tuple) and len(col) == 2:
label, val = col
if str(label).lower() == "title":
updated_cols.append((label, new_title))
changed = True
else:
updated_cols.append(col)
else:
updated_cols.append(col)
if changed:
item["columns"] = updated_cols
def _refresh_result_table_title(new_title: str, target_hash: Optional[str], target_path: Optional[str]) -> None:
"""Refresh the cached result table with an updated title and redisplay it."""
try:
last_table = ctx.get_last_result_table()
items = ctx.get_last_result_items()
if not last_table or not items:
return
updated_items = []
match_found = False
for item in items:
try:
if _matches_target(item, target_hash, target_path):
_update_item_title_fields(item, new_title)
match_found = True
except Exception:
pass
updated_items.append(item)
if not match_found:
return
from result_table import ResultTable # Local import to avoid circular dependency
new_table = last_table.copy_with_title(getattr(last_table, "title", ""))
for item in updated_items:
new_table.add_result(item)
# Keep the underlying history intact; update only the overlay so @.. can
# clear the overlay then continue back to prior tables (e.g., the search list).
ctx.set_last_result_table_overlay(new_table, updated_items)
except Exception:
pass
def _refresh_tags_view(res: Any, target_hash: Optional[str], store_name: Optional[str], target_path: Optional[str], config: Dict[str, Any]) -> None:
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
try:
from cmdlets import get_tag as get_tag_cmd # type: ignore
except Exception:
return
if not target_hash or not store_name:
return
refresh_args: List[str] = ["-hash", target_hash, "-store", store_name]
try:
subject = ctx.get_last_result_subject()
if subject and _matches_target(subject, target_hash, target_path):
get_tag_cmd._run(subject, refresh_args, config)
return
except Exception:
pass
try:
get_tag_cmd._run(res, refresh_args, config)
except Exception:
pass
class Add_Tag(Cmdlet):
"""Class-based add-tags cmdlet with Cmdlet metadata inheritance."""
def __init__(self) -> None:
super().__init__(
name="add-tags",
summary="Add tags to a file in a store.",
usage="add-tags -store <store> [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
arg=[
SharedArgs.HASH,
SharedArgs.STORE,
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tags non-temporary files)."),
CmdletArg("tags", type="string", required=False, description="One or more tags to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tags from pipeline payload.", variadic=True),
],
detail=[
"- By default, only tags non-temporary files (from pipelines). Use --all to tag everything.",
"- Requires a store backend: use -store or pipe items that include store.",
"- If -hash is not provided, uses the piped item's hash (or derives from its path when possible).",
"- Multiple tags can be comma-separated or space-separated.",
"- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult",
"- Tags can also reference lists with curly braces: add-tags {philosophy} \"other:tag\"",
"- Use -duplicate to copy EXISTING tag values to new namespaces:",
" Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)",
" Inferred format: -duplicate title,album,artist (first is source, rest are targets)",
"- The source namespace must already exist in the file being tagged.",
"- Target namespaces that already have a value are skipped (not overwritten).",
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
],
exec=self.run,
)
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Add tags to a file with smart filtering for pipeline results."""
if should_show_help(args):
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
return 0
# Parse arguments
parsed = parse_cmdlet_args(args, self)
# Check for --all flag
include_temp = parsed.get("all", False)
# Normalize input to list
results = normalize_result_input(result)
# Filter by temp status (unless --all is set)
if not include_temp:
results = filter_results_by_temp(results, include_temp=False)
if not results:
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
return 1
# Get tags from arguments (or fallback to pipeline payload)
raw_tags = parsed.get("tags", [])
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
# Fallback: if no tags provided explicitly, try to pull from first result payload
if not raw_tags and results:
first = results[0]
payload_tags = None
# Try multiple tag lookup strategies in order
tag_lookups = [
lambda x: getattr(x, "tags", None),
lambda x: x.get("tags") if isinstance(x, dict) else None,
]
for lookup in tag_lookups:
try:
payload_tags = lookup(first)
if payload_tags:
break
except (AttributeError, TypeError, KeyError):
continue
if payload_tags:
if isinstance(payload_tags, str):
raw_tags = [payload_tags]
elif isinstance(payload_tags, list):
raw_tags = payload_tags
# Handle -list argument (convert to {list} syntax)
list_arg = parsed.get("list")
if list_arg:
for l in list_arg.split(','):
l = l.strip()
if l:
raw_tags.append(f"{{{l}}}")
# Parse and expand tags
tags_to_add = parse_tag_arguments(raw_tags)
tags_to_add = expand_tag_groups(tags_to_add)
# Allow hash override via namespaced token (e.g., "hash:abcdef...")
extracted_hash = None
filtered_tags: List[str] = []
for tag in tags_to_add:
if isinstance(tag, str) and tag.lower().startswith("hash:"):
_, _, hash_val = tag.partition(":")
if hash_val:
extracted_hash = normalize_hash(hash_val.strip())
continue
filtered_tags.append(tag)
tags_to_add = filtered_tags
if not tags_to_add:
log("No tags provided to add", file=sys.stderr)
return 1
# Get other flags (hash override can come from -hash or hash: token)
hash_override = normalize_hash(parsed.get("hash")) or extracted_hash
duplicate_arg = parsed.get("duplicate")
# Tags ARE provided - apply them to each store-backed result
total_added = 0
total_modified = 0
store_override = parsed.get("store")
for res in results:
store_name: Optional[str]
raw_hash: Optional[str]
raw_path: Optional[str]
if isinstance(res, models.PipeObject):
store_name = store_override or res.store
raw_hash = res.hash
raw_path = res.path
elif isinstance(res, dict):
store_name = store_override or res.get("store")
raw_hash = res.get("hash")
raw_path = res.get("path")
else:
ctx.emit(res)
continue
if not store_name:
log("[add_tags] Error: Missing -store and item has no store field", file=sys.stderr)
return 1
resolved_hash = normalize_hash(hash_override) if hash_override else normalize_hash(raw_hash)
if not resolved_hash and raw_path:
try:
p = Path(str(raw_path))
stem = p.stem
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
resolved_hash = stem.lower()
elif p.exists() and p.is_file():
resolved_hash = sha256_file(p)
except Exception:
resolved_hash = None
if not resolved_hash:
log("[add_tags] Warning: Item missing usable hash (and could not derive from path); skipping", file=sys.stderr)
ctx.emit(res)
continue
try:
backend = Store(config)[str(store_name)]
except Exception as exc:
log(f"[add_tags] Error: Unknown store '{store_name}': {exc}", file=sys.stderr)
return 1
try:
existing_tags, _src = backend.get_tag(resolved_hash, config=config)
except Exception:
existing_tags = []
existing_tags_list = [t for t in (existing_tags or []) if isinstance(t, str)]
existing_lower = {t.lower() for t in existing_tags_list}
original_title = _extract_title_tag(existing_tags_list)
# Per-item tag list (do not mutate shared list)
item_tags_to_add = list(tags_to_add)
item_tags_to_add = collapse_namespace_tags(item_tags_to_add, "title", prefer="last")
# Handle -duplicate logic (copy existing tags to new namespaces)
if duplicate_arg:
parts = str(duplicate_arg).split(':')
source_ns = ""
targets: list[str] = []
if len(parts) > 1:
source_ns = parts[0]
targets = [t.strip() for t in parts[1].split(',') if t.strip()]
else:
parts2 = str(duplicate_arg).split(',')
if len(parts2) > 1:
source_ns = parts2[0]
targets = [t.strip() for t in parts2[1:] if t.strip()]
if source_ns and targets:
source_prefix = source_ns.lower() + ":"
for t in existing_tags_list:
if not t.lower().startswith(source_prefix):
continue
value = t.split(":", 1)[1]
for target_ns in targets:
new_tag = f"{target_ns}:{value}"
if new_tag.lower() not in existing_lower:
item_tags_to_add.append(new_tag)
# Namespace replacement: delete old namespace:* when adding namespace:value
removed_namespace_tags: list[str] = []
for new_tag in item_tags_to_add:
if not isinstance(new_tag, str) or ":" not in new_tag:
continue
ns = new_tag.split(":", 1)[0].strip()
if not ns:
continue
ns_prefix = ns.lower() + ":"
for t in existing_tags_list:
if t.lower().startswith(ns_prefix) and t.lower() != new_tag.lower():
removed_namespace_tags.append(t)
removed_namespace_tags = sorted({t for t in removed_namespace_tags})
actual_tags_to_add = [t for t in item_tags_to_add if isinstance(t, str) and t.lower() not in existing_lower]
changed = False
if removed_namespace_tags:
try:
backend.delete_tag(resolved_hash, removed_namespace_tags, config=config)
changed = True
except Exception as exc:
log(f"[add_tags] Warning: Failed deleting namespace tags: {exc}", file=sys.stderr)
if actual_tags_to_add:
try:
backend.add_tag(resolved_hash, actual_tags_to_add, config=config)
changed = True
except Exception as exc:
log(f"[add_tags] Warning: Failed adding tags: {exc}", file=sys.stderr)
if changed:
total_added += len(actual_tags_to_add)
total_modified += 1
try:
refreshed_tags, _src2 = backend.get_tag(resolved_hash, config=config)
refreshed_list = [t for t in (refreshed_tags or []) if isinstance(t, str)]
except Exception:
refreshed_list = existing_tags_list
# Update the result's tags using canonical field
if isinstance(res, models.PipeObject):
res.tags = refreshed_list
elif isinstance(res, dict):
res["tags"] = refreshed_list
final_title = _extract_title_tag(refreshed_list)
_apply_title_to_result(res, final_title)
if final_title and (not original_title or final_title.lower() != original_title.lower()):
_refresh_result_table_title(final_title, resolved_hash, raw_path)
if changed:
_refresh_tags_view(res, resolved_hash, str(store_name), raw_path, config)
ctx.emit(res)
log(
f"[add_tags] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
file=sys.stderr,
)
return 0
CMDLET = Add_Tag()
+3 -3
View File
@@ -103,11 +103,11 @@ def get_cmdlet_metadata(cmd_name: str) -> Optional[Dict[str, Any]]:
base = {} base = {}
name = getattr(data, "name", base.get("name", cmd_name)) or cmd_name name = getattr(data, "name", base.get("name", cmd_name)) or cmd_name
aliases = getattr(data, "aliases", base.get("aliases", [])) or [] aliases = getattr(data, "alias", base.get("alias", [])) or []
usage = getattr(data, "usage", base.get("usage", "")) usage = getattr(data, "usage", base.get("usage", ""))
summary = getattr(data, "summary", base.get("summary", "")) summary = getattr(data, "summary", base.get("summary", ""))
details = getattr(data, "details", base.get("details", [])) or [] details = getattr(data, "detail", base.get("detail", [])) or []
args_list = getattr(data, "args", base.get("args", [])) or [] args_list = getattr(data, "arg", base.get("arg", [])) or []
args = [_normalize_arg(arg) for arg in args_list] args = [_normalize_arg(arg) for arg in args_list]
return { return {
+4 -4
View File
@@ -33,7 +33,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
- Emits only non-temporary results - Emits only non-temporary results
Typical pipeline usage: Typical pipeline usage:
download-data url | screen-shot | add-tag "tag" --all | cleanup download-data url | screen-shot | add-tag -store local "tag" --all | cleanup
""" """
# Help # Help
@@ -67,7 +67,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
deleted_count += 1 deleted_count += 1
# Clean up any associated sidecar files # Clean up any associated sidecar files
for ext in ['.tags', '.metadata']: for ext in ['.tag', '.metadata']:
sidecar = path_obj.parent / (path_obj.name + ext) sidecar = path_obj.parent / (path_obj.name + ext)
if sidecar.exists(): if sidecar.exists():
try: try:
@@ -98,9 +98,9 @@ CMDLET = Cmdlet(
detail=[ detail=[
"- Accepts pipeline results that may contain temporary files (screenshots, intermediate artifacts)", "- Accepts pipeline results that may contain temporary files (screenshots, intermediate artifacts)",
"- Deletes files marked with is_temp=True from disk", "- Deletes files marked with is_temp=True from disk",
"- Also cleans up associated sidecar files (.tags, .metadata)", "- Also cleans up associated sidecar files (.tag, .metadata)",
"- Emits only non-temporary results for further processing", "- Emits only non-temporary results for further processing",
"- Typical usage at end of pipeline: ... | add-tag \"tag\" --all | cleanup", "- Typical usage at end of pipeline: ... | add-tag -store local \"tag\" --all | cleanup",
"- Exit code 0 if cleanup successful, 1 if no results to process", "- Exit code 0 if cleanup successful, 1 if no results to process",
], ],
) )
+5 -2
View File
@@ -100,8 +100,11 @@ class Delete_File(Cmdlet):
log(f"Local delete failed: {exc}", file=sys.stderr) log(f"Local delete failed: {exc}", file=sys.stderr)
# Remove common sidecars regardless of file removal success # Remove common sidecars regardless of file removal success
for sidecar in (path.with_suffix(".tags"), path.with_suffix(".tags.txt"), for sidecar in (
path.with_suffix(".metadata"), path.with_suffix(".notes")): path.with_suffix(".tag"),
path.with_suffix(".metadata"),
path.with_suffix(".notes"),
):
try: try:
if sidecar.exists() and sidecar.is_file(): if sidecar.exists() and sidecar.is_file():
sidecar.unlink() sidecar.unlink()
+1 -1
View File
@@ -302,7 +302,7 @@ def _process_deletion(tags: list[str], file_hash: str | None, path: str | None,
del_title_set = {t.lower() for t in title_tags} del_title_set = {t.lower() for t in title_tags}
remaining_titles = [t for t in current_titles if t.lower() not in del_title_set] remaining_titles = [t for t in current_titles if t.lower() not in del_title_set]
if current_titles and not remaining_titles: if current_titles and not remaining_titles:
log("Cannot delete the last title: tag. Add a replacement title first (add-tag \"title:new title\").", file=sys.stderr) log("Cannot delete the last title: tag. Add a replacement title first (add-tags \"title:new title\").", file=sys.stderr)
return False return False
try: try:
+168 -63
View File
@@ -1,12 +1,10 @@
"""Download files directly via HTTP (non-yt-dlp url). """Generic file downloader.
Focused cmdlet for direct file downloads from: Supports:
- PDFs, images, documents - Direct HTTP file URLs (PDFs, images, documents; non-yt-dlp)
- url not supported by yt-dlp - Piped provider items (uses provider.download when available)
- LibGen sources
- Direct file links
No streaming site logic - pure HTTP download with retries. No streaming site logic; use download-media for yt-dlp/streaming.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,10 +15,17 @@ from typing import Any, Dict, List, Optional, Sequence
from SYS.download import DownloadError, _download_direct_file from SYS.download import DownloadError, _download_direct_file
from SYS.logger import log, debug from SYS.logger import log, debug
from models import DownloadOptions
import pipeline as pipeline_context import pipeline as pipeline_context
from ._shared import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args, register_url_with_local_library, coerce_to_pipe_object from ._shared import (
Cmdlet,
CmdletArg,
SharedArgs,
parse_cmdlet_args,
register_url_with_local_library,
coerce_to_pipe_object,
get_field,
)
class Download_File(Cmdlet): class Download_File(Cmdlet):
@@ -30,14 +35,13 @@ class Download_File(Cmdlet):
"""Initialize download-file cmdlet.""" """Initialize download-file cmdlet."""
super().__init__( super().__init__(
name="download-file", name="download-file",
summary="Download files directly via HTTP (PDFs, images, documents)", summary="Download files via HTTP or provider handlers",
usage="download-file <url> [options] or search-file | download-file [options]", usage="download-file <url> [options] OR @N | download-file [options]",
alias=["dl-file", "download-http"], alias=["dl-file", "download-http"],
arg=[ arg=[
CmdletArg(name="url", type="string", required=False, description="URL to download (direct file links)", variadic=True), CmdletArg(name="output", type="string", alias="o", description="Output directory (overrides defaults)"),
CmdletArg(name="-url", type="string", description="URL to download (alias for positional argument)", variadic=True), SharedArgs.URL,
CmdletArg(name="output", type="string", alias="o", description="Output filename (auto-detected if not specified)"),
SharedArgs.URL
], ],
detail=["Download files directly via HTTP without yt-dlp processing.", "For streaming sites, use download-media."], detail=["Download files directly via HTTP without yt-dlp processing.", "For streaming sites, use download-media."],
exec=self.run, exec=self.run,
@@ -60,13 +64,21 @@ class Download_File(Cmdlet):
# Parse arguments # Parse arguments
parsed = parse_cmdlet_args(args, self) parsed = parse_cmdlet_args(args, self)
# Extract options # Extract explicit URL args (if any)
raw_url = parsed.get("url", []) raw_url = parsed.get("url", [])
if isinstance(raw_url, str): if isinstance(raw_url, str):
raw_url = [raw_url] raw_url = [raw_url]
# If no URL args were provided, fall back to piped results (provider items)
piped_items: List[Any] = []
if not raw_url: if not raw_url:
log("No url to download", file=sys.stderr) if isinstance(result, list):
piped_items = result
elif result:
piped_items = [result]
if not raw_url and not piped_items:
log("No url or piped items to download", file=sys.stderr)
return 1 return 1
# Get output directory # Get output directory
@@ -76,27 +88,78 @@ class Download_File(Cmdlet):
debug(f"Output directory: {final_output_dir}") debug(f"Output directory: {final_output_dir}")
# Download each URL # Download each URL and/or provider item
downloaded_count = 0 downloaded_count = 0
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
custom_output = parsed.get("output")
for url in raw_url: # Provider lookup is optional; keep import local to avoid overhead if unused
get_search_provider = None
SearchResult = None
try: try:
debug(f"Processing: {url}") from Provider.registry import get_search_provider as _get_search_provider, SearchResult as _SearchResult
# Direct HTTP download get_search_provider = _get_search_provider
result_obj = _download_direct_file(url, final_output_dir, quiet=quiet_mode) SearchResult = _SearchResult
debug(f"Download completed, building pipe object...") except Exception:
pipe_obj_dict = self._build_pipe_object(result_obj, url, final_output_dir) get_search_provider = None
debug(f"Emitting result to pipeline...") SearchResult = None
pipeline_context.emit(pipe_obj_dict)
def _emit_local_file(downloaded_path: Path, source: Optional[str], title_hint: Optional[str], tags_hint: Optional[List[str]], media_kind_hint: Optional[str], full_metadata: Optional[Dict[str, Any]]) -> None:
title_val = (title_hint or downloaded_path.stem or "Unknown").strip() or downloaded_path.stem
hash_value = self._compute_file_hash(downloaded_path)
tag: List[str] = []
if tags_hint:
tag.extend([str(t) for t in tags_hint if t])
if not any(str(t).lower().startswith("title:") for t in tag):
tag.insert(0, f"title:{title_val}")
payload: Dict[str, Any] = {
"path": str(downloaded_path),
"hash": hash_value,
"title": title_val,
"action": "cmdlet:download-file",
"download_mode": "file",
"store": "local",
"media_kind": media_kind_hint or "file",
"tag": tag,
}
if full_metadata:
payload["full_metadata"] = full_metadata
if source and str(source).startswith("http"):
payload["url"] = source
elif source:
payload["source_url"] = source
pipeline_context.emit(payload)
# Automatically register url with local library # Automatically register url with local library
if pipe_obj_dict.get("url"): if payload.get("url"):
pipe_obj = coerce_to_pipe_object(pipe_obj_dict) pipe_obj = coerce_to_pipe_object(payload)
register_url_with_local_library(pipe_obj, config) register_url_with_local_library(pipe_obj, config)
# 1) Explicit URL downloads
for url in raw_url:
try:
debug(f"Processing URL: {url}")
result_obj = _download_direct_file(url, final_output_dir, quiet=quiet_mode)
file_path = None
if hasattr(result_obj, "path"):
file_path = getattr(result_obj, "path")
elif isinstance(result_obj, dict):
file_path = result_obj.get("path")
if not file_path:
file_path = str(result_obj)
downloaded_path = Path(str(file_path))
_emit_local_file(
downloaded_path=downloaded_path,
source=url,
title_hint=downloaded_path.stem,
tags_hint=[f"title:{downloaded_path.stem}"],
media_kind_hint="file",
full_metadata=None,
)
downloaded_count += 1 downloaded_count += 1
debug("✓ Downloaded and emitted") debug("✓ Downloaded and emitted")
@@ -105,6 +168,72 @@ class Download_File(Cmdlet):
except Exception as e: except Exception as e:
log(f"Error processing {url}: {e}", file=sys.stderr) log(f"Error processing {url}: {e}", file=sys.stderr)
# 2) Provider item downloads (piped results)
for item in piped_items:
try:
table = get_field(item, "table")
title = get_field(item, "title")
target = get_field(item, "path") or get_field(item, "url")
media_kind = get_field(item, "media_kind")
tags_val = get_field(item, "tag")
tags_list: Optional[List[str]]
if isinstance(tags_val, list):
tags_list = [str(t) for t in tags_val if t]
else:
tags_list = None
full_metadata = get_field(item, "full_metadata")
if (not full_metadata) and isinstance(item, dict) and isinstance(item.get("extra"), dict):
extra_md = item["extra"].get("full_metadata")
if isinstance(extra_md, dict):
full_metadata = extra_md
# If this looks like a provider item and providers are available, prefer provider.download()
downloaded_path: Optional[Path] = None
if table and get_search_provider and SearchResult:
provider = get_search_provider(str(table), config)
if provider is not None:
sr = SearchResult(
table=str(table),
title=str(title or "Unknown"),
path=str(target or ""),
full_metadata=full_metadata if isinstance(full_metadata, dict) else {},
)
debug(f"[download-file] Downloading provider item via {table}: {sr.title}")
downloaded_path = provider.download(sr, final_output_dir)
# Fallback: if we have a direct HTTP URL, download it directly
if downloaded_path is None and isinstance(target, str) and target.startswith("http"):
debug(f"[download-file] Provider item looks like direct URL, downloading: {target}")
result_obj = _download_direct_file(target, final_output_dir, quiet=quiet_mode)
file_path = None
if hasattr(result_obj, "path"):
file_path = getattr(result_obj, "path")
elif isinstance(result_obj, dict):
file_path = result_obj.get("path")
if not file_path:
file_path = str(result_obj)
downloaded_path = Path(str(file_path))
if downloaded_path is None:
log(f"Cannot download item (no provider handler / unsupported target): {title or target}", file=sys.stderr)
continue
_emit_local_file(
downloaded_path=downloaded_path,
source=str(target) if target else None,
title_hint=str(title) if title else downloaded_path.stem,
tags_hint=tags_list,
media_kind_hint=str(media_kind) if media_kind else None,
full_metadata=full_metadata if isinstance(full_metadata, dict) else None,
)
downloaded_count += 1
except DownloadError as e:
log(f"Download failed: {e}", file=sys.stderr)
except Exception as e:
log(f"Error downloading item: {e}", file=sys.stderr)
if downloaded_count > 0: if downloaded_count > 0:
debug(f"✓ Successfully processed {downloaded_count} file(s)") debug(f"✓ Successfully processed {downloaded_count} file(s)")
return 0 return 0
@@ -118,6 +247,16 @@ class Download_File(Cmdlet):
def _resolve_output_dir(self, parsed: Dict[str, Any], config: Dict[str, Any]) -> Optional[Path]: def _resolve_output_dir(self, parsed: Dict[str, Any], config: Dict[str, Any]) -> Optional[Path]:
"""Resolve the output directory from storage location or config.""" """Resolve the output directory from storage location or config."""
output_dir_arg = parsed.get("output")
if output_dir_arg:
try:
out_path = Path(str(output_dir_arg)).expanduser()
out_path.mkdir(parents=True, exist_ok=True)
return out_path
except Exception as e:
log(f"Cannot use output directory {output_dir_arg}: {e}", file=sys.stderr)
return None
storage_location = parsed.get("storage") storage_location = parsed.get("storage")
# Priority 1: --storage flag # Priority 1: --storage flag
@@ -148,40 +287,6 @@ class Download_File(Cmdlet):
return final_output_dir return final_output_dir
def _build_pipe_object(self, download_result: Any, url: str, output_dir: Path) -> Dict[str, Any]:
"""Create a PipeObject-compatible dict from a download result."""
# Try to get file path from result
file_path = None
if hasattr(download_result, 'path'):
file_path = download_result.path
elif isinstance(download_result, dict) and 'path' in download_result:
file_path = download_result['path']
if not file_path:
# Fallback: assume result is the path itself
file_path = str(download_result)
media_path = Path(file_path)
hash_value = self._compute_file_hash(media_path)
title = media_path.stem
# Build tags with title for searchability
tags = [f"title:{title}"]
# Canonical pipeline payload (no legacy aliases)
return {
"path": str(media_path),
"hash": hash_value,
"title": title,
"file_title": title,
"action": "cmdlet:download-file",
"download_mode": "file",
"url": url or (download_result.get('url') if isinstance(download_result, dict) else None),
"store": "local",
"media_kind": "file",
"tags": tags,
}
def _compute_file_hash(self, filepath: Path) -> str: def _compute_file_hash(self, filepath: Path) -> str:
"""Compute SHA256 hash of a file.""" """Compute SHA256 hash of a file."""
import hashlib import hashlib
+4 -4
View File
@@ -1391,11 +1391,11 @@ class Download_Media(Cmdlet):
media_path = Path(download_result.path) media_path = Path(download_result.path)
hash_value = download_result.hash_value or self._compute_file_hash(media_path) hash_value = download_result.hash_value or self._compute_file_hash(media_path)
title = info.get("title") or media_path.stem title = info.get("title") or media_path.stem
tags = list(download_result.tags or []) tag = list(download_result.tag or [])
# Add title tag for searchability # Add title tag for searchability
if title and f"title:{title}" not in tags: if title and f"title:{title}" not in tag:
tags.insert(0, f"title:{title}") tag.insert(0, f"title:{title}")
# Build a single canonical URL field; prefer yt-dlp provided webpage_url or info.url, # Build a single canonical URL field; prefer yt-dlp provided webpage_url or info.url,
# but fall back to the original requested URL. If multiple unique urls are available, # but fall back to the original requested URL. If multiple unique urls are available,
@@ -1424,7 +1424,7 @@ class Download_Media(Cmdlet):
"hash": hash_value, "hash": hash_value,
"title": title, "title": title,
"url": final_url, "url": final_url,
"tags": tags, "tag": tag,
"action": "cmdlet:download-media", "action": "cmdlet:download-media",
# download_mode removed (deprecated), keep media_kind # download_mode removed (deprecated), keep media_kind
"store": getattr(opts, "storage_name", None) or getattr(opts, "storage_location", None) or "PATH", "store": getattr(opts, "storage_name", None) or getattr(opts, "storage_location", None) or "PATH",
-157
View File
@@ -1,157 +0,0 @@
"""download-provider cmdlet: Download items from external providers."""
from __future__ import annotations
from typing import Any, Dict, Sequence, List, Optional
from pathlib import Path
import sys
import json
from SYS.logger import log, debug
from Provider.registry import get_search_provider, SearchResult
from SYS.utils import unique_path
from ._shared import Cmdlet, CmdletArg, should_show_help, get_field, coerce_to_pipe_object
import pipeline as ctx
# Optional dependencies
try:
from config import get_local_storage_path, resolve_output_dir
except Exception: # pragma: no cover
get_local_storage_path = None # type: ignore
resolve_output_dir = None # type: ignore
class Download_Provider(Cmdlet):
"""Download items from external providers."""
def __init__(self):
super().__init__(
name="download-provider",
summary="Download items from external providers (soulseek, libgen, etc).",
usage="download-provider [item] [-output DIR]",
arg=[
CmdletArg("output", type="string", alias="o", description="Output directory"),
],
detail=[
"Download items from external providers.",
"Usually called automatically by @N selection on provider results.",
"Can be used manually by piping a provider result item.",
],
exec=self.run
)
self.register()
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"""Execute download-provider cmdlet."""
if should_show_help(args):
ctx.emit(self.__dict__)
return 0
# Parse arguments
output_dir_arg = None
i = 0
while i < len(args):
arg = args[i]
if arg in ("-output", "--output", "-o") and i + 1 < len(args):
output_dir_arg = args[i+1]
i += 2
else:
i += 1
# Determine output directory
if output_dir_arg:
output_dir = Path(output_dir_arg)
elif resolve_output_dir:
output_dir = resolve_output_dir(config)
else:
output_dir = Path("./downloads")
output_dir.mkdir(parents=True, exist_ok=True)
# Process input result
items = []
if isinstance(result, list):
items = result
elif result:
items = [result]
if not items:
log("No items to download", file=sys.stderr)
return 1
success_count = 0
for item in items:
try:
# Extract provider info
table = get_field(item, "table")
if not table:
log(f"Skipping item without provider info: {item}", file=sys.stderr)
continue
provider = get_search_provider(table, config)
if not provider:
log(f"Provider '{table}' not available for download", file=sys.stderr)
continue
# Reconstruct SearchResult if needed
# The provider.download method expects a SearchResult object or compatible dict
if isinstance(item, dict):
# Ensure full_metadata is present
if "full_metadata" not in item and "extra" in item:
item["full_metadata"] = item["extra"].get("full_metadata", {})
search_result = SearchResult(
table=table,
title=item.get("title", "Unknown"),
path=item.get("path", ""),
full_metadata=item.get("full_metadata", {})
)
else:
# Assume it's an object with attributes (like PipeObject)
full_metadata = getattr(item, "full_metadata", {})
# Check extra dict if full_metadata is missing/empty
if not full_metadata and hasattr(item, "extra") and isinstance(item.extra, dict):
full_metadata = item.extra.get("full_metadata", {})
# Fallback: if full_metadata key isn't there, maybe the extra dict IS the metadata
if not full_metadata and "username" in item.extra:
full_metadata = item.extra
search_result = SearchResult(
table=table,
title=getattr(item, "title", "Unknown"),
path=getattr(item, "path", ""),
full_metadata=full_metadata
)
debug(f"[download-provider] Downloading '{search_result.title}' via {table}...")
downloaded_path = provider.download(search_result, output_dir)
if downloaded_path:
debug(f"[download-provider] Download successful: {downloaded_path}")
# Create PipeObject for the downloaded file
pipe_obj = coerce_to_pipe_object({
"path": str(downloaded_path),
"title": search_result.title,
"table": "local", # Now it's a local file
"media_kind": getattr(item, "media_kind", "other"),
"tags": getattr(item, "tags", []),
"full_metadata": search_result.full_metadata
})
ctx.emit(pipe_obj)
success_count += 1
else:
log(f"Download failed for '{search_result.title}'", file=sys.stderr)
except Exception as e:
log(f"Error downloading item: {e}", file=sys.stderr)
import traceback
debug(traceback.format_exc())
if success_count > 0:
return 0
return 1
# Register cmdlet instance
Download_Provider_Instance = Download_Provider()
+23 -27
View File
@@ -2,7 +2,7 @@
This cmdlet retrieves tags for a selected result, supporting both: This cmdlet retrieves tags for a selected result, supporting both:
- Hydrus Network (for files with hash) - Hydrus Network (for files with hash)
- Local sidecar files (.tags) - Local sidecar files (.tag)
In interactive mode: navigate with numbers, add/delete tags In interactive mode: navigate with numbers, add/delete tags
In pipeline mode: display tags as read-only table, emit as structured JSON In pipeline mode: display tags as read-only table, emit as structured JSON
@@ -89,9 +89,9 @@ def _emit_tags_as_table(
from result_table import ResultTable from result_table import ResultTable
# Create ResultTable with just tag column (no title) # Create ResultTable with just tag column (no title)
table_title = "Tags" table_title = "Tag"
if item_title: if item_title:
table_title = f"Tags: {item_title}" table_title = f"Tag: {item_title}"
if file_hash: if file_hash:
table_title += f" [{file_hash[:8]}]" table_title += f" [{file_hash[:8]}]"
@@ -195,19 +195,19 @@ def _rename_file_if_title_tag(media: Optional[Path], tags_added: List[str]) -> b
return False return False
# Build sidecar paths BEFORE renaming the file # Build sidecar paths BEFORE renaming the file
old_sidecar = Path(str(file_path) + '.tags') old_sidecar = Path(str(file_path) + '.tag')
new_sidecar = Path(str(new_file_path) + '.tags') new_sidecar = Path(str(new_file_path) + '.tag')
# Rename file # Rename file
try: try:
file_path.rename(new_file_path) file_path.rename(new_file_path)
log(f"Renamed file: {old_name}{new_name}") log(f"Renamed file: {old_name}{new_name}")
# Rename .tags sidecar if it exists # Rename .tag sidecar if it exists
if old_sidecar.exists(): if old_sidecar.exists():
try: try:
old_sidecar.rename(new_sidecar) old_sidecar.rename(new_sidecar)
log(f"Renamed sidecar: {old_name}.tags{new_name}.tags") log(f"Renamed sidecar: {old_name}.tag → {new_name}.tag")
except Exception as e: except Exception as e:
log(f"Failed to rename sidecar: {e}", file=sys.stderr) log(f"Failed to rename sidecar: {e}", file=sys.stderr)
@@ -232,7 +232,7 @@ def _apply_result_updates_from_tags(result: Any, tag_list: List[str]) -> None:
def _handle_title_rename(old_path: Path, tags_list: List[str]) -> Optional[Path]: def _handle_title_rename(old_path: Path, tags_list: List[str]) -> Optional[Path]:
"""If a title: tag is present, rename the file and its .tags sidecar to match. """If a title: tag is present, rename the file and its .tag sidecar to match.
Returns the new path if renamed, otherwise returns None. Returns the new path if renamed, otherwise returns None.
""" """
@@ -267,10 +267,10 @@ def _handle_title_rename(old_path: Path, tags_list: List[str]) -> Optional[Path]
old_path.rename(new_path) old_path.rename(new_path)
log(f"Renamed file: {old_name}{new_name}", file=sys.stderr) log(f"Renamed file: {old_name}{new_name}", file=sys.stderr)
# Rename the .tags sidecar if it exists # Rename the .tag sidecar if it exists
old_tags_path = old_path.parent / (old_name + '.tags') old_tags_path = old_path.parent / (old_name + '.tag')
if old_tags_path.exists(): if old_tags_path.exists():
new_tags_path = old_path.parent / (new_name + '.tags') new_tags_path = old_path.parent / (new_name + '.tag')
if new_tags_path.exists(): if new_tags_path.exists():
log(f"Warning: Target sidecar already exists: {new_tags_path.name}", file=sys.stderr) log(f"Warning: Target sidecar already exists: {new_tags_path.name}", file=sys.stderr)
else: else:
@@ -368,14 +368,12 @@ def _write_sidecar(p: Path, media: Path, tag_list: List[str], url: List[str], ha
return media return media
def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional[str], extra: Optional[Dict[str, Any]] = None, store_label: Optional[str] = None) -> int:
"""Emit tags as structured payload to pipeline.
Also emits individual tag objects to _PIPELINE_LAST_ITEMS so they can be selected by index. def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional[str], extra: Optional[Dict[str, Any]] = None, store_label: Optional[str] = None) -> int:
""" """Emit tag values as structured payload to pipeline."""
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"source": source, "source": source,
"tags": list(tags_list), "tag": list(tags_list),
"count": len(tags_list), "count": len(tags_list),
} }
if hash_value: if hash_value:
@@ -388,11 +386,9 @@ def _emit_tag_payload(source: str, tags_list: List[str], *, hash_value: Optional
if store_label: if store_label:
label = store_label label = store_label
elif ctx.get_stage_context() is not None: elif ctx.get_stage_context() is not None:
label = "tags" label = "tag"
if label: if label:
ctx.store_value(label, payload) ctx.store_value(label, payload)
if ctx.get_stage_context() is not None and label.lower() != "tags":
ctx.store_value("tags", payload)
# Emit individual TagItem objects so they can be selected by bare index # Emit individual TagItem objects so they can be selected by bare index
# When in pipeline, emit individual TagItem objects # When in pipeline, emit individual TagItem objects
@@ -1065,7 +1061,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
return 1 return 1
output = { output = {
"title": title, "title": title,
"tags": tags, "tag": tags,
"formats": [(label, fmt_id) for label, fmt_id in formats], "formats": [(label, fmt_id) for label, fmt_id in formats],
"playlist_items": playlist_items, "playlist_items": playlist_items,
} }
@@ -1080,7 +1076,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Prefer identifier tags (ISBN/OLID/etc.) when available; fallback to title/filename # Prefer identifier tags (ISBN/OLID/etc.) when available; fallback to title/filename
identifier_tags: List[str] = [] identifier_tags: List[str] = []
result_tags = get_field(result, "tags", None) result_tags = get_field(result, "tag", None)
if isinstance(result_tags, list): if isinstance(result_tags, list):
identifier_tags = [str(t) for t in result_tags if isinstance(t, (str, bytes))] identifier_tags = [str(t) for t in result_tags if isinstance(t, (str, bytes))]
@@ -1160,7 +1156,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
row.add_column("Album", item.get("album", "")) row.add_column("Album", item.get("album", ""))
row.add_column("Year", item.get("year", "")) row.add_column("Year", item.get("year", ""))
payload = { payload = {
"tags": tags, "tag": tags,
"provider": provider.name, "provider": provider.name,
"title": item.get("title"), "title": item.get("title"),
"artist": item.get("artist"), "artist": item.get("artist"),
@@ -1169,7 +1165,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"hash": hash_for_payload, "hash": hash_for_payload,
"store": store_for_payload, "store": store_for_payload,
"extra": { "extra": {
"tags": tags, "tag": tags,
"provider": provider.name, "provider": provider.name,
}, },
} }
@@ -1236,13 +1232,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Build a subject payload representing the file whose tags are being shown # Build a subject payload representing the file whose tags are being shown
subject_store = get_field(result, "store", None) or store_name subject_store = get_field(result, "store", None) or store_name
subject_payload: Dict[str, Any] = { subject_payload: Dict[str, Any] = {
"tags": list(current), "tag": list(current),
"title": item_title, "title": item_title,
"name": item_title, "name": item_title,
"store": subject_store, "store": subject_store,
"service_name": service_name, "service_name": service_name,
"extra": { "extra": {
"tags": list(current), "tag": list(current),
}, },
} }
if file_hash: if file_hash:
@@ -1288,9 +1284,9 @@ class Get_Tag(Cmdlet):
"""Initialize get-tag cmdlet.""" """Initialize get-tag cmdlet."""
super().__init__( super().__init__(
name="get-tag", name="get-tag",
summary="Get tags from Hydrus or local sidecar metadata", summary="Get tag values from Hydrus or local sidecar metadata",
usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url|provider>]", usage="get-tag [-hash <sha256>] [--store <key>] [--emit] [-scrape <url|provider>]",
alias=["tags"], alias=[],
arg=[ arg=[
SharedArgs.HASH, SharedArgs.HASH,
CmdletArg( CmdletArg(
+20 -52
View File
@@ -12,7 +12,7 @@ from models import DownloadOptions
from config import resolve_output_dir from config import resolve_output_dir
import subprocess as _subprocess import subprocess as _subprocess
import shutil as _shutil import shutil as _shutil
from ._shared import parse_cmdlet_args from ._shared import create_pipe_object_result, parse_cmdlet_args
try: try:
from PyPDF2 import PdfWriter, PdfReader from PyPDF2 import PdfWriter, PdfReader
@@ -136,35 +136,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if target_path and target_path.exists(): if target_path and target_path.exists():
source_files.append(target_path) source_files.append(target_path)
# Track the .tags file for this source # Track the .tag file for this source
tags_file = target_path.with_suffix(target_path.suffix + '.tags') tags_file = target_path.with_suffix(target_path.suffix + '.tag')
if tags_file.exists(): if tags_file.exists():
source_tags_files.append(tags_file) source_tags_files.append(tags_file)
# Try to read hash, tags, url, and relationships from .tags sidecar file
try: try:
tags_content = tags_file.read_text(encoding='utf-8') source_tags.extend(read_tags_from_file(tags_file) if HAS_METADATA_API else [])
for line in tags_content.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('hash:'):
hash_value = line[5:].strip()
if hash_value:
source_hashes.append(hash_value)
elif line.startswith('url:') or line.startswith('url:'):
# Extract url from tags file
url_value = line.split(':', 1)[1].strip() if ':' in line else ''
if url_value and url_value not in source_url:
source_url.append(url_value)
elif line.startswith('relationship:'):
# Extract relationships from tags file
rel_value = line.split(':', 1)[1].strip() if ':' in line else ''
if rel_value and rel_value not in source_relationships:
source_relationships.append(rel_value)
else:
# Collect actual tags (not metadata like hash: or url:)
source_tags.append(line)
except Exception: except Exception:
pass pass
@@ -254,8 +231,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log(f"Merged {len(source_files)} files into: {output_path}", file=sys.stderr) log(f"Merged {len(source_files)} files into: {output_path}", file=sys.stderr)
# Create .tags sidecar file for the merged output using unified API # Create .tag sidecar file for the merged output using unified API
tags_path = output_path.with_suffix(output_path.suffix + '.tags') tags_path = output_path.with_suffix(output_path.suffix + '.tag')
try: try:
# Start with title tag # Start with title tag
merged_tags = [f"title:{output_path.stem}"] merged_tags = [f"title:{output_path.stem}"]
@@ -312,29 +289,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception as e: except Exception as e:
log(f"Warning: Could not create sidecar: {e}", file=sys.stderr) log(f"Warning: Could not create sidecar: {e}", file=sys.stderr)
# Emit PipelineItem so the merged file can be piped to next command # Emit a PipeObject-compatible dict so the merged file can be piped to next command
try: try:
# Try to import PipelineItem from downlow module from SYS.utils import sha256_file
try: merged_hash = sha256_file(output_path)
from downlow import PipelineItem merged_item = create_pipe_object_result(
except ImportError: source="local",
# Fallback: create a simple object with the required attributes identifier=output_path.name,
class SimpleItem: file_path=str(output_path),
def __init__(self, target, title, media_kind, tags=None, url=None): cmdlet_name="merge-file",
self.target = target
self.title = title
self.media_kind = media_kind
self.tags = tags or []
self.url = url or []
self.store = "local"
PipelineItem = SimpleItem
merged_item = PipelineItem(
target=str(output_path),
title=output_path.stem, title=output_path.stem,
hash_value=merged_hash,
tag=merged_tags,
url=source_url,
media_kind=file_kind, media_kind=file_kind,
tags=merged_tags, # Include merged tags
url=source_url # Include known url
) )
# Clear previous results to ensure only the merged file is passed down # Clear previous results to ensure only the merged file is passed down
ctx.clear_last_result() ctx.clear_last_result()
@@ -348,7 +316,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Always delete source files if they were downloaded playlist items (temp files) # Always delete source files if they were downloaded playlist items (temp files)
# We can detect this if they are in the temp download directory or if we tracked them # We can detect this if they are in the temp download directory or if we tracked them
if delete_after or True: # Force delete for now as merge consumes them if delete_after or True: # Force delete for now as merge consumes them
# First delete all .tags files # First delete all .tag files
for tags_file in source_tags_files: for tags_file in source_tags_files:
try: try:
tags_file.unlink() tags_file.unlink()
@@ -490,8 +458,8 @@ def _merge_audio(files: List[Path], output: Path, output_format: str) -> bool:
title = file_path.stem # Default to filename without extension title = file_path.stem # Default to filename without extension
if HAS_METADATA_API: if HAS_METADATA_API:
try: try:
# Try to read tags from .tags sidecar file # Try to read tags from .tag sidecar file
tags_file = file_path.with_suffix(file_path.suffix + '.tags') tags_file = file_path.with_suffix(file_path.suffix + '.tag')
if tags_file.exists(): if tags_file.exists():
tags = read_tags_from_file(tags_file) tags = read_tags_from_file(tags_file)
if tags: if tags:
-14
View File
@@ -1,14 +0,0 @@
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",
)
+4 -4
View File
@@ -121,7 +121,7 @@ class ScreenshotOptions:
wait_after_load: float = 2.0 wait_after_load: float = 2.0
wait_for_article: bool = False wait_for_article: bool = False
replace_video_posters: bool = True replace_video_posters: bool = True
tags: Sequence[str] = () tag: Sequence[str] = ()
archive: bool = False archive: bool = False
archive_timeout: float = ARCHIVE_TIMEOUT archive_timeout: float = ARCHIVE_TIMEOUT
url: Sequence[str] = () url: Sequence[str] = ()
@@ -136,7 +136,7 @@ class ScreenshotResult:
"""Details about the captured screenshot.""" """Details about the captured screenshot."""
path: Path path: Path
tags_applied: List[str] tag_applied: List[str]
archive_url: List[str] archive_url: List[str]
url: List[str] url: List[str]
warnings: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list)
@@ -481,11 +481,11 @@ def _capture_screenshot(options: ScreenshotOptions) -> ScreenshotResult:
if archives: if archives:
url = unique_preserve_order([*url, *archives]) url = unique_preserve_order([*url, *archives])
applied_tags = unique_preserve_order(list(tag for tag in options.tags if tag.strip())) applied_tag = unique_preserve_order(list(tag for tag in options.tag if tag.strip()))
return ScreenshotResult( return ScreenshotResult(
path=destination, path=destination,
tags_applied=applied_tags, tag_applied=applied_tag,
archive_url=archive_url, archive_url=archive_url,
url=url, url=url,
warnings=warnings, warnings=warnings,
+9 -8
View File
@@ -27,9 +27,9 @@ except Exception: # pragma: no cover
resolve_output_dir = None # type: ignore resolve_output_dir = None # type: ignore
try: try:
from API.HydrusNetwork import HydrusClient, HydrusRequestError from API.HydrusNetwork import HydrusNetwork, HydrusRequestError
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
HydrusClient = None # type: ignore HydrusNetwork = None # type: ignore
HydrusRequestError = RuntimeError # type: ignore HydrusRequestError = RuntimeError # type: ignore
try: try:
@@ -47,7 +47,7 @@ class SearchRecord:
path: str path: str
size_bytes: int | None = None size_bytes: int | None = None
duration_seconds: str | None = None duration_seconds: str | None = None
tags: str | None = None tag: str | None = None
hash: str | None = None hash: str | None = None
def as_dict(self) -> dict[str, str]: def as_dict(self) -> dict[str, str]:
@@ -56,8 +56,8 @@ class SearchRecord:
payload["size"] = str(self.size_bytes) payload["size"] = str(self.size_bytes)
if self.duration_seconds: if self.duration_seconds:
payload["duration"] = self.duration_seconds payload["duration"] = self.duration_seconds
if self.tags: if self.tag:
payload["tags"] = self.tags payload["tag"] = self.tag
if self.hash: if self.hash:
payload["hash"] = self.hash payload["hash"] = self.hash
return payload return payload
@@ -233,16 +233,17 @@ class Search_Store(Cmdlet):
from Store import Store from Store import Store
storage = Store(config=config or {}) storage = Store(config=config or {})
from Store._base import Store as BaseStore
backend_to_search = storage_backend or None backend_to_search = storage_backend or None
if backend_to_search: if backend_to_search:
searched_backends.append(backend_to_search) searched_backends.append(backend_to_search)
target_backend = storage[backend_to_search] target_backend = storage[backend_to_search]
if not callable(getattr(target_backend, 'search_file', None)): if type(target_backend).search is BaseStore.search:
log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr) log(f"Backend '{backend_to_search}' does not support searching", file=sys.stderr)
db.update_worker_status(worker_id, 'error') db.update_worker_status(worker_id, 'error')
return 1 return 1
results = target_backend.search_store(query, limit=limit) results = target_backend.search(query, limit=limit)
else: else:
from API.HydrusNetwork import is_hydrus_available from API.HydrusNetwork import is_hydrus_available
hydrus_available = is_hydrus_available(config or {}) hydrus_available = is_hydrus_available(config or {})
@@ -256,7 +257,7 @@ class Search_Store(Cmdlet):
continue continue
searched_backends.append(backend_name) searched_backends.append(backend_name)
backend_results = backend.search_store(query, limit=limit - len(all_results)) backend_results = backend.search(query, limit=limit - len(all_results))
if backend_results: if backend_results:
all_results.extend(backend_results) all_results.extend(backend_results)
if len(all_results) >= limit: if len(all_results) >= limit:
+5 -5
View File
@@ -17,7 +17,7 @@ from ._shared import (
CmdletArg, CmdletArg,
parse_cmdlet_args, parse_cmdlet_args,
normalize_result_input, normalize_result_input,
extract_tags_from_result, extract_tag_from_result,
extract_title_from_result extract_title_from_result
) )
import pipeline as ctx import pipeline as ctx
@@ -33,7 +33,7 @@ CMDLET = Cmdlet(
], ],
detail=[ detail=[
"Creates a new file with 'clip_' prefix in the filename/title.", "Creates a new file with 'clip_' prefix in the filename/title.",
"Inherits tags from the source file.", "Inherits tag values from the source file.",
"Adds a relationship to the source file (if hash is available).", "Adds a relationship to the source file (if hash is available).",
"Output can be piped to add-file.", "Output can be piped to add-file.",
] ]
@@ -185,8 +185,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
except Exception: except Exception:
pass pass
# 2. Get tags # 2. Get tag values
tags = extract_tags_from_result(item) tags = extract_tag_from_result(item)
# 3. Get title and modify it # 3. Get title and modify it
title = extract_title_from_result(item) title = extract_title_from_result(item)
@@ -266,7 +266,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
result_dict = { result_dict = {
"path": str(output_path), "path": str(output_path),
"title": new_title, "title": new_title,
"tags": new_tags, "tag": new_tags,
"media_kind": "video", # Assumption, or derive "media_kind": "video", # Assumption, or derive
"hash": clip_hash, # Pass calculated hash "hash": clip_hash, # Pass calculated hash
"relationships": { "relationships": {
+9 -1
View File
@@ -15,8 +15,16 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None:
if hasattr(cmdlet_obj, "name") and cmdlet_obj.name: if hasattr(cmdlet_obj, "name") and cmdlet_obj.name:
registry[cmdlet_obj.name.replace("_", "-").lower()] = run_fn registry[cmdlet_obj.name.replace("_", "-").lower()] = run_fn
# Cmdlet uses 'alias' (List[str]). Some older objects may use 'aliases'.
aliases = []
if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"):
aliases.extend(getattr(cmdlet_obj, "alias") or [])
if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"): if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"):
for alias in cmdlet_obj.aliases: aliases.extend(getattr(cmdlet_obj, "aliases") or [])
for alias in aliases:
if not alias:
continue
registry[alias.replace("_", "-").lower()] = run_fn registry[alias.replace("_", "-").lower()] = run_fn
@@ -1,5 +1,6 @@
from typing import List, Dict, Any from typing import List, Dict, Any
from ._shared import Cmdlet, CmdletArg
from cmdlets._shared import Cmdlet, CmdletArg
from config import load_config, save_config from config import load_config, save_config
CMDLET = Cmdlet( CMDLET = Cmdlet(
+2
View File
@@ -181,3 +181,5 @@ CMDLET = Cmdlet(
), ),
], ],
) )
CMDLET.exec = _run
+5 -3
View File
@@ -585,14 +585,16 @@ def _queue_items(items: List[Any], clear_first: bool = False, config: Optional[D
# Treat any http(s) target as yt-dlp candidate. If the Python yt-dlp # Treat any http(s) target as yt-dlp candidate. If the Python yt-dlp
# module is available we also check more deeply, but default to True # module is available we also check more deeply, but default to True
# so MPV can use its ytdl hooks for remote streaming sites. # so MPV can use its ytdl hooks for remote streaming sites.
is_hydrus_target = _is_hydrus_path(str(target), hydrus_url)
try: try:
is_ytdlp = target.startswith("http") or is_url_supported_by_ytdlp(target) # Hydrus direct file URLs should not be treated as yt-dlp targets.
is_ytdlp = (not is_hydrus_target) and (target.startswith("http") or is_url_supported_by_ytdlp(target))
except Exception: except Exception:
is_ytdlp = target.startswith("http") is_ytdlp = (not is_hydrus_target) and target.startswith("http")
# Use memory:// M3U hack to pass title to MPV # Use memory:// M3U hack to pass title to MPV
# Skip for yt-dlp url to ensure proper handling # Skip for yt-dlp url to ensure proper handling
if title and not is_ytdlp: if title and (is_hydrus_target or not is_ytdlp):
# Sanitize title for M3U (remove newlines) # Sanitize title for M3U (remove newlines)
safe_title = title.replace('\n', ' ').replace('\r', '') safe_title = title.replace('\n', ' ').replace('\r', '')
m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}" m3u_content = f"#EXTM3U\n#EXTINF:-1,{safe_title}\n{target}"
File diff suppressed because it is too large Load Diff
+281 -119
View File
@@ -33,6 +33,13 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
sha256_file = None # type: ignore[assignment] sha256_file = None # type: ignore[assignment]
try: # Optional metadata helper for audio files
import mutagen # type: ignore
except ImportError: # pragma: no cover - best effort
mutagen = None # type: ignore
from SYS.utils import sanitize_metadata_value, unique_preserve_order
try: try:
from helpers.hydrus import HydrusClient, HydrusRequestError, HydrusRequestSpec # type: ignore from helpers.hydrus import HydrusClient, HydrusRequestError, HydrusRequestSpec # type: ignore
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
@@ -50,6 +57,223 @@ else: # pragma: no cover
_CURRENT_RELATIONSHIP_TRACKER = FileRelationshipTracker() _CURRENT_RELATIONSHIP_TRACKER = FileRelationshipTracker()
def prepare_ffmpeg_metadata(payload: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""Derive ffmpeg/mutagen metadata tags from a generic metadata payload.
This is not Hydrus-specific; it is used by exporters/converters.
"""
if not isinstance(payload, dict):
return {}
metadata: Dict[str, str] = {}
def set_field(key: str, raw: Any, limit: int = 2000) -> None:
sanitized = sanitize_metadata_value(raw)
if not sanitized:
return
if len(sanitized) > limit:
sanitized = sanitized[:limit]
metadata[key] = sanitized
set_field("title", payload.get("title"))
set_field("artist", payload.get("artist"), 512)
set_field("album", payload.get("album"), 512)
set_field("date", payload.get("year"), 20)
comment = payload.get("comment")
tags_value = payload.get("tag")
tag_strings: List[str] = []
artists_from_tags: List[str] = []
albums_from_tags: List[str] = []
genres_from_tags: List[str] = []
if isinstance(tags_value, list):
for raw_tag in tags_value:
if raw_tag is None:
continue
if not isinstance(raw_tag, str):
raw_tag = str(raw_tag)
tag = raw_tag.strip()
if not tag:
continue
tag_strings.append(tag)
namespace, sep, value = tag.partition(":")
if sep and value:
ns = namespace.strip().lower()
value = value.strip()
if ns in {"artist", "creator", "author", "performer"}:
artists_from_tags.append(value)
elif ns in {"album", "series", "collection", "group"}:
albums_from_tags.append(value)
elif ns in {"genre", "rating"}:
genres_from_tags.append(value)
elif ns in {"comment", "description"} and not comment:
comment = value
elif ns in {"year", "date"} and not payload.get("year"):
set_field("date", value, 20)
else:
genres_from_tags.append(tag)
if "artist" not in metadata and artists_from_tags:
set_field("artist", ", ".join(unique_preserve_order(artists_from_tags)[:3]), 512)
if "album" not in metadata and albums_from_tags:
set_field("album", unique_preserve_order(albums_from_tags)[0], 512)
if genres_from_tags:
set_field("genre", ", ".join(unique_preserve_order(genres_from_tags)[:5]), 256)
if tag_strings:
joined_tags = ", ".join(tag_strings[:50])
set_field("keywords", joined_tags, 2000)
if not comment:
comment = joined_tags
if comment:
set_field("comment", comment, 2000)
set_field("description", comment, 2000)
return metadata
def apply_mutagen_metadata(path: Path, metadata: Dict[str, str], fmt: str) -> None:
"""Best-effort metadata writing for audio containers."""
if fmt != "audio":
return
if not metadata:
return
if mutagen is None:
return
try:
audio = mutagen.File(path, easy=True) # type: ignore[attr-defined]
except Exception as exc: # pragma: no cover - best effort only
log(f"mutagen load failed: {exc}", file=sys.stderr)
return
if audio is None:
return
field_map = {
"title": "title",
"artist": "artist",
"album": "album",
"genre": "genre",
"comment": "comment",
"description": "comment",
"date": "date",
}
changed = False
for source_key, target_key in field_map.items():
value = metadata.get(source_key)
if not value:
continue
try:
audio[target_key] = [value]
changed = True
except Exception: # pragma: no cover
continue
if not changed:
return
try:
audio.save()
except Exception as exc: # pragma: no cover
log(f"mutagen save failed: {exc}", file=sys.stderr)
def build_ffmpeg_command(
ffmpeg_path: str,
input_path: Path,
output_path: Path,
fmt: str,
max_width: int,
metadata: Optional[Dict[str, str]] = None,
) -> List[str]:
"""Build an ffmpeg command line for common export formats."""
cmd: List[str] = [ffmpeg_path, "-y", "-i", str(input_path)]
if fmt in {"mp4", "webm"} and max_width and max_width > 0:
cmd.extend(["-vf", f"scale='min({max_width},iw)':-2"])
if metadata:
for key, value in metadata.items():
cmd.extend(["-metadata", f"{key}={value}"])
# Video formats
if fmt == "mp4":
cmd.extend(
[
"-c:v",
"libx265",
"-preset",
"medium",
"-crf",
"26",
"-tag:v",
"hvc1",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"192k",
"-movflags",
"+faststart",
]
)
elif fmt == "webm":
cmd.extend(
[
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
"32",
"-c:a",
"libopus",
"-b:a",
"160k",
]
)
cmd.extend(["-f", "webm"])
# Audio formats
elif fmt == "mp3":
cmd.extend(["-vn", "-c:a", "libmp3lame", "-b:a", "192k"])
cmd.extend(["-f", "mp3"])
elif fmt == "flac":
cmd.extend(["-vn", "-c:a", "flac"])
cmd.extend(["-f", "flac"])
elif fmt == "wav":
cmd.extend(["-vn", "-c:a", "pcm_s16le"])
cmd.extend(["-f", "wav"])
elif fmt == "aac":
cmd.extend(["-vn", "-c:a", "aac", "-b:a", "192k"])
cmd.extend(["-f", "adts"])
elif fmt == "m4a":
cmd.extend(["-vn", "-c:a", "aac", "-b:a", "192k"])
cmd.extend(["-f", "ipod"])
elif fmt == "ogg":
cmd.extend(["-vn", "-c:a", "libvorbis", "-b:a", "192k"])
cmd.extend(["-f", "ogg"])
elif fmt == "opus":
cmd.extend(["-vn", "-c:a", "libopus", "-b:a", "192k"])
cmd.extend(["-f", "opus"])
elif fmt == "audio":
# Legacy format name for mp3
cmd.extend(["-vn", "-c:a", "libmp3lame", "-b:a", "192k"])
cmd.extend(["-f", "mp3"])
elif fmt != "copy":
raise ValueError(f"Unsupported format: {fmt}")
cmd.append(str(output_path))
return cmd
def field(obj: Any, name: str, value: Any = None) -> Any: def field(obj: Any, name: str, value: Any = None) -> Any:
"""Get or set a field on dict or object. """Get or set a field on dict or object.
@@ -131,9 +355,9 @@ def value_normalize(value: str) -> str:
def import_pending_sidecars(db_root: Path, db: Any) -> None: def import_pending_sidecars(db_root: Path, db: Any) -> None:
"""Import pending sidecars (.tag/.tags/.metadata/.notes) into the database.""" """Import pending sidecars (.tag/.metadata/.notes) into the database."""
try: try:
sidecar_patterns = ['**/*.tag', '**/*.tags', '**/*.metadata', '**/*.notes'] sidecar_patterns = ['**/*.tag', '**/*.metadata', '**/*.notes']
for pattern in sidecar_patterns: for pattern in sidecar_patterns:
for sidecar_path in db_root.glob(pattern): for sidecar_path in db_root.glob(pattern):
@@ -174,7 +398,7 @@ def import_pending_sidecars(db_root: Path, db: Any) -> None:
if not file_id: if not file_id:
continue continue
if sidecar_path.suffix in {'.tag', '.tags'}: if sidecar_path.suffix == '.tag':
try: try:
content = sidecar_path.read_text(encoding='utf-8') content = sidecar_path.read_text(encoding='utf-8')
except Exception: except Exception:
@@ -395,7 +619,7 @@ def imdb_tag(imdb_id: str) -> Dict[str, object]:
break break
if cast_names: if cast_names:
_extend_tags(tags, "cast", cast_names) _extend_tags(tags, "cast", cast_names)
return {"source": "imdb", "id": canonical_id, "tags": tags} return {"source": "imdb", "id": canonical_id, "tag": tags}
def fetch_musicbrainz_tags(mbid: str, entity: str) -> Dict[str, object]: def fetch_musicbrainz_tags(mbid: str, entity: str) -> Dict[str, object]:
if not musicbrainzngs: if not musicbrainzngs:
raise RuntimeError("musicbrainzngs package is not available") raise RuntimeError("musicbrainzngs package is not available")
@@ -451,7 +675,7 @@ def fetch_musicbrainz_tags(mbid: str, entity: str) -> Dict[str, object]:
for genre in genre_list: for genre in genre_list:
if isinstance(genre, dict) and genre.get("name"): if isinstance(genre, dict) and genre.get("name"):
_add_tag(tags, "genre", genre["name"]) _add_tag(tags, "genre", genre["name"])
return {"source": "musicbrainz", "id": mbid, "tags": tags, "entity": entity} return {"source": "musicbrainz", "id": mbid, "tag": tags, "entity": entity}
def fetch_openlibrary_tags(ol_id: str) -> Dict[str, object]: def fetch_openlibrary_tags(ol_id: str) -> Dict[str, object]:
@@ -461,7 +685,7 @@ def fetch_openlibrary_tags(ol_id: str) -> Dict[str, object]:
ol_id: OpenLibrary ID (e.g., 'OL123456M' for a book) ol_id: OpenLibrary ID (e.g., 'OL123456M' for a book)
Returns: Returns:
Dictionary with 'tags' key containing list of extracted tags Dictionary with 'tag' key containing list of extracted tags
""" """
import urllib.request import urllib.request
@@ -573,7 +797,7 @@ def fetch_openlibrary_tags(ol_id: str) -> Dict[str, object]:
description = description.get("value") description = description.get("value")
_add_tag(tags, "summary", description) _add_tag(tags, "summary", description)
return {"source": "openlibrary", "id": ol_id, "tags": tags} return {"source": "openlibrary", "id": ol_id, "tag": tags}
def _append_unique(target: List[str], seen: Set[str], value: Optional[str]) -> None: def _append_unique(target: List[str], seen: Set[str], value: Optional[str]) -> None:
@@ -1328,25 +1552,16 @@ def _normalise_string_list(values: Optional[Iterable[Any]]) -> List[str]:
def _derive_sidecar_path(media_path: Path) -> Path: def _derive_sidecar_path(media_path: Path) -> Path:
"""Return preferred sidecar path (.tag), falling back to legacy .tags if it exists. """Return sidecar path (.tag)."""
Keeps backward compatibility by preferring existing .tags, but new writes use .tag.
"""
try: try:
preferred = media_path.parent / (media_path.name + '.tag') preferred = media_path.parent / (media_path.name + '.tag')
legacy = media_path.parent / (media_path.name + '.tags')
except ValueError: except ValueError:
preferred = media_path.with_name(media_path.name + '.tag') preferred = media_path.with_name(media_path.name + '.tag')
legacy = media_path.with_name(media_path.name + '.tags')
# Prefer legacy if it already exists to avoid duplicate sidecars
if legacy.exists():
return legacy
return preferred return preferred
def _read_sidecar_metadata(sidecar_path: Path) -> tuple[Optional[str], List[str], List[str]]: def _read_sidecar_metadata(sidecar_path: Path) -> tuple[Optional[str], List[str], List[str]]:
"""Read hash, tags, and url from .tags sidecar file. """Read hash, tags, and url from sidecar file.
Consolidated with read_tags_from_file - this extracts extra metadata (hash, url). Consolidated with read_tags_from_file - this extracts extra metadata (hash, url).
""" """
@@ -1389,7 +1604,7 @@ def _read_sidecar_metadata(sidecar_path: Path) -> tuple[Optional[str], List[str]
def rename(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. """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. If a title: tag is present, renames the file and any .tag/.metadata sidecars.
Args: Args:
file_path: Path to the file to potentially rename file_path: Path to the file to potentially rename
@@ -1432,10 +1647,10 @@ def rename(file_path: Path, tags: Iterable[str]) -> Optional[Path]:
file_path.rename(new_path) file_path.rename(new_path)
debug(f"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 # Rename the .tag sidecar if it exists
old_tags_path = file_path.parent / (old_name + '.tags') old_tags_path = file_path.parent / (old_name + '.tag')
if old_tags_path.exists(): if old_tags_path.exists():
new_tags_path = file_path.parent / (new_name + '.tags') new_tags_path = file_path.parent / (new_name + '.tag')
if new_tags_path.exists(): if new_tags_path.exists():
try: try:
new_tags_path.unlink() new_tags_path.unlink()
@@ -1508,14 +1723,6 @@ def write_tags(media_path: Path, tags: Iterable[str], url: Iterable[str], hash_v
if lines: if lines:
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8") sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
debug(f"Tags: {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')]:
if legacy_path.exists() and legacy_path != sidecar:
try:
legacy_path.unlink()
except OSError:
pass
else: else:
try: try:
sidecar.unlink() sidecar.unlink()
@@ -1691,7 +1898,7 @@ def _locate_sidecar_by_hash(hash_value: str, roots: Iterable[Path]) -> Optional[
continue continue
if not root_path.exists() or not root_path.is_dir(): if not root_path.exists() or not root_path.is_dir():
continue continue
for pattern in ('*.tags', '*.tags.txt'): for pattern in ('*.tag',):
try: try:
iterator = root_path.rglob(pattern) iterator = root_path.rglob(pattern)
except OSError: except OSError:
@@ -1711,80 +1918,35 @@ def _locate_sidecar_by_hash(hash_value: str, roots: Iterable[Path]) -> Optional[
def sync_sidecar(payload: Dict[str, Any]) -> Dict[str, Any]: def sync_sidecar(payload: Dict[str, Any]) -> Dict[str, Any]:
path_value = payload.get('path') path_value = payload.get('path')
sidecar_path: Optional[Path] = None if not path_value:
media_path: Optional[Path] = None raise ValueError('path is required to synchronise sidecar')
if path_value:
candidate = Path(str(path_value)).expanduser() candidate = Path(str(path_value)).expanduser()
if candidate.suffix.lower() in {'.tags', '.tags.txt'}: if candidate.suffix.lower() == '.tag':
sidecar_path = candidate sidecar_path = candidate
else: else:
media_path = candidate sidecar_path = _derive_sidecar_path(candidate)
hash_input = payload.get('hash')
hash_value = None tags = _normalise_string_list(payload.get('tag'))
if hash_input: if not tags and sidecar_path.exists():
hash_value = _normalize_hash(hash_input) tags = read_tags_from_file(sidecar_path)
tags = _normalise_string_list(payload.get('tags'))
url = _normalise_string_list(payload.get('url'))
if media_path is not None:
sidecar_path = _derive_sidecar_path(media_path)
search_roots = _collect_search_roots(payload)
if sidecar_path is None and hash_value:
located = _locate_sidecar_by_hash(hash_value, search_roots)
if located is not None:
sidecar_path = located
if sidecar_path is None:
if media_path is not None:
sidecar_path = _derive_sidecar_path(media_path)
elif hash_value:
return {
'error': 'not_found',
'hash': hash_value,
'tags': tags,
'url': url,
}
else:
raise ValueError('path or hash is required to synchronise sidecar')
existing_hash, existing_tags, existing_known = _read_sidecar_metadata(sidecar_path)
if not tags:
tags = existing_tags
if not url:
url = existing_known
hash_line = hash_value or existing_hash
title_value: Optional[str] = None
for tag in tags:
if isinstance(tag, str):
if tag.lower().startswith('title:'):
title_value = tag.split(':', 1)[1].strip() if ':' in tag else ''
if title_value == '':
title_value = None
break
lines: List[str] = []
if hash_line:
lines.append(f'hash:{hash_line}')
lines.extend(tags)
lines.extend(f'url:{url}' for url in url)
sidecar_path.parent.mkdir(parents=True, exist_ok=True) sidecar_path.parent.mkdir(parents=True, exist_ok=True)
if lines: if tags:
sidecar_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') sidecar_path.write_text('\n'.join(tags) + '\n', encoding='utf-8')
else: return {
'path': str(sidecar_path),
'tag': tags,
}
try: try:
sidecar_path.unlink() sidecar_path.unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
return { return {
'path': str(sidecar_path), 'path': str(sidecar_path),
'hash': hash_line, 'tag': [],
'tags': [],
'url': [],
'deleted': True, 'deleted': True,
'title': title_value,
}
return {
'path': str(sidecar_path),
'hash': hash_line,
'tags': tags,
'url': url,
'title': title_value,
} }
@@ -1901,16 +2063,16 @@ def apply_tag_mutation(payload: Dict[str, Any], operation: str = 'add') -> Dict[
result['updated'] = True result['updated'] = True
return result return result
else: # local else: # local
tags = _clean_existing_tags(payload.get('tags')) tag = _clean_existing_tags(payload.get('tag'))
if operation == 'add': if operation == 'add':
new_tag = _normalize_tag(payload.get('new_tag')) new_tag = _normalize_tag(payload.get('new_tag'))
if not new_tag: if not new_tag:
raise ValueError('new_tag is required') raise ValueError('new_tag is required')
added = new_tag not in tags added = new_tag not in tag
if added: if added:
tags.append(new_tag) tag.append(new_tag)
return {'tags': tags, 'added': added} return {'tag': tag, 'added': added}
else: # update else: # update
old_tag = _normalize_tag(payload.get('old_tag')) old_tag = _normalize_tag(payload.get('old_tag'))
@@ -1920,17 +2082,17 @@ def apply_tag_mutation(payload: Dict[str, Any], operation: str = 'add') -> Dict[
remaining = [] remaining = []
removed_count = 0 removed_count = 0
for tag in tags: for item in tag:
if tag == old_tag: if item == old_tag:
removed_count += 1 removed_count += 1
else: else:
remaining.append(tag) remaining.append(item)
if new_tag and removed_count > 0: if new_tag and removed_count > 0:
remaining.extend([new_tag] * removed_count) remaining.extend([new_tag] * removed_count)
updated = removed_count > 0 or (bool(new_tag) and new_tag not in tags) updated = removed_count > 0 or (bool(new_tag) and new_tag not in tag)
return {'tags': remaining, 'updated': updated, 'removed_count': removed_count} return {'tag': remaining, 'updated': updated, 'removed_count': removed_count}
def extract_ytdlp_tags(entry: Dict[str, Any]) -> List[str]: def extract_ytdlp_tags(entry: Dict[str, Any]) -> List[str]:
@@ -2181,13 +2343,13 @@ def merge_multiple_tag_lists(
def read_tags_from_file(file_path: Path) -> List[str]: def read_tags_from_file(file_path: Path) -> List[str]:
"""Read and normalize tags from .tags sidecar file. """Read and normalize tags from .tag sidecar file.
This is the UNIFIED API for reading .tags files across all cmdlets. This is the UNIFIED API for reading .tag files across all cmdlets.
Handles normalization, deduplication, and format validation. Handles normalization, deduplication, and format validation.
Args: Args:
file_path: Path to .tags sidecar file file_path: Path to .tag sidecar file
Returns: Returns:
List of normalized tag strings List of normalized tag strings
@@ -2196,7 +2358,7 @@ def read_tags_from_file(file_path: Path) -> List[str]:
FileNotFoundError: If file doesn't exist FileNotFoundError: If file doesn't exist
Example: Example:
>>> tags = read_tags_from_file(Path('file.txt.tags')) >>> tags = read_tags_from_file(Path('file.txt.tag'))
>>> debug(tags) >>> debug(tags)
['artist:Beatles', 'album:Abbey Road'] ['artist:Beatles', 'album:Abbey Road']
""" """
@@ -2386,13 +2548,13 @@ def write_tags_to_file(
url: Optional[List[str]] = None, url: Optional[List[str]] = None,
append: bool = False append: bool = False
) -> bool: ) -> bool:
"""Write tags to .tags sidecar file. """Write tags to .tag sidecar file.
This is the UNIFIED API for writing .tags files across all cmdlets. This is the UNIFIED API for writing .tag files across all cmdlets.
Uses consistent format and handles file creation/overwriting. Uses consistent format and handles file creation/overwriting.
Args: Args:
file_path: Path to .tags file (will be created if doesn't exist) file_path: Path to .tag file (will be created if doesn't exist)
tags: List of tags to write tags: List of tags to write
source_hashes: Optional source file hashes (written as source:hash1,hash2) source_hashes: Optional source file hashes (written as source:hash1,hash2)
url: Optional known url (each written on separate line as url:url) url: Optional known url (each written on separate line as url:url)
@@ -2406,7 +2568,7 @@ def write_tags_to_file(
Example: Example:
>>> tags = ['artist:Beatles', 'album:Abbey Road'] >>> tags = ['artist:Beatles', 'album:Abbey Road']
>>> write_tags_to_file(Path('file.txt.tags'), tags) >>> write_tags_to_file(Path('file.txt.tag'), tags)
True True
""" """
file_path = Path(file_path) file_path = Path(file_path)
@@ -2448,7 +2610,7 @@ def normalize_tags_from_source(
Universal function to normalize tags from different sources: Universal function to normalize tags from different sources:
- yt-dlp entry dicts - yt-dlp entry dicts
- Raw tag lists - Raw tag lists
- .tags file content strings - .tag file content strings
- Metadata dictionaries - Metadata dictionaries
Args: Args:
@@ -2575,12 +2737,12 @@ def expand_metadata_tag(payload: Dict[str, Any]) -> Dict[str, Any]:
else: else:
data = fetch_musicbrainz_tags(request['id'], request['entity']) data = fetch_musicbrainz_tags(request['id'], request['entity'])
except Exception as exc: # pragma: no cover - network/service errors except Exception as exc: # pragma: no cover - network/service errors
return {'tags': tags, 'error': str(exc)} return {'tag': tags, 'error': str(exc)}
# Add tags from fetched data (no namespace, just unique append) # Add tags from fetched data (no namespace, just unique append)
for tag in (data.get('tags') or []): for tag in (data.get('tag') or []):
_append_unique(tags, seen, tag) _append_unique(tags, seen, tag)
result = { result = {
'tags': tags, 'tag': tags,
'source': request['source'], 'source': request['source'],
'id': request['id'], 'id': request['id'],
} }
@@ -2597,7 +2759,7 @@ def build_remote_bundle(metadata: Optional[Dict[str, Any]], existing: Optional[S
_append_unique(tags, seen, tag) _append_unique(tags, seen, tag)
# Add tags from various sources # Add tags from various sources
for tag in (metadata.get("tags") or []): for tag in (metadata.get("tag") or []):
_append_unique(tags, seen, tag) _append_unique(tags, seen, tag)
for tag in (metadata.get("categories") or []): for tag in (metadata.get("categories") or []):
_append_unique(tags, seen, tag) _append_unique(tags, seen, tag)
@@ -2632,7 +2794,7 @@ def build_remote_bundle(metadata: Optional[Dict[str, Any]], existing: Optional[S
source_url = context.get("source_url") or metadata.get("original_url") or metadata.get("webpage_url") or metadata.get("url") source_url = context.get("source_url") or metadata.get("original_url") or metadata.get("webpage_url") or metadata.get("url")
clean_title = value_normalize(str(title_value)) if title_value is not None else None clean_title = value_normalize(str(title_value)) if title_value is not None else None
result = { result = {
"tags": tags, "tag": tags,
"title": clean_title, "title": clean_title,
"source_url": _sanitize_url(source_url), "source_url": _sanitize_url(source_url),
"duration": _coerce_duration(metadata), "duration": _coerce_duration(metadata),
@@ -2747,9 +2909,9 @@ def hydrus_fetch_url(payload: Optional[str] = typer.Option(None, "--payload", he
debug(json.dumps(error_payload, ensure_ascii=False), flush=True) debug(json.dumps(error_payload, ensure_ascii=False), flush=True)
raise typer.Exit(code=1) raise typer.Exit(code=1)
@app.command(name="sync-sidecar", help="Synchronise .tags sidecar with supplied data") @app.command(name="sync-sidecar", help="Synchronise .tag sidecar with supplied data")
def sync_sidecar_cmd(payload: Optional[str] = typer.Option(None, "--payload", help="JSON payload; reads stdin if omitted")): def sync_sidecar_cmd(payload: Optional[str] = typer.Option(None, "--payload", help="JSON payload; reads stdin if omitted")):
"""Synchronise .tags sidecar with supplied data.""" """Synchronise .tag sidecar with supplied data."""
try: try:
payload_data = _load_payload(payload) payload_data = _load_payload(payload)
result = sync_sidecar(payload_data) result = sync_sidecar(payload_data)
+13 -13
View File
@@ -14,7 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Protocol, TextIO, Tuple
@dataclass(slots=True) @dataclass(slots=True)
class PipeObject: class PipeObject:
"""Unified pipeline object for tracking files, metadata, tags, and relationships through the pipeline. """Unified pipeline object for tracking files, metadata, tag values, and relationships through the pipeline.
This is the single source of truth for all result data in the pipeline. Uses the hash+store This is the single source of truth for all result data in the pipeline. Uses the hash+store
canonical pattern for file identification. canonical pattern for file identification.
@@ -22,7 +22,7 @@ class PipeObject:
Attributes: Attributes:
hash: SHA-256 hash of the file (canonical identifier) hash: SHA-256 hash of the file (canonical identifier)
store: Storage backend name (e.g., 'default', 'hydrus', 'test', 'home') store: Storage backend name (e.g., 'default', 'hydrus', 'test', 'home')
tags: List of extracted or assigned tags tag: List of extracted or assigned tag values
title: Human-readable title if applicable title: Human-readable title if applicable
source_url: URL where the object came from source_url: URL where the object came from
duration: Duration in seconds if applicable duration: Duration in seconds if applicable
@@ -37,7 +37,7 @@ class PipeObject:
""" """
hash: str hash: str
store: str store: str
tags: List[str] = field(default_factory=list) tag: List[str] = field(default_factory=list)
title: Optional[str] = None title: Optional[str] = None
url: Optional[str] = None url: Optional[str] = None
source_url: Optional[str] = None source_url: Optional[str] = None
@@ -90,9 +90,9 @@ class PipeObject:
hash_display = self.hash or "N/A" hash_display = self.hash or "N/A"
store_display = self.store or "N/A" store_display = self.store or "N/A"
title_display = self.title or "N/A" title_display = self.title or "N/A"
tags_display = ", ".join(self.tags[:3]) if self.tags else "[]" tag_display = ", ".join(self.tag[:3]) if self.tag else "[]"
if len(self.tags) > 3: if len(self.tag) > 3:
tags_display += f" (+{len(self.tags) - 3} more)" tag_display += f" (+{len(self.tag) - 3} more)"
file_path_display = self.path or "N/A" file_path_display = self.path or "N/A"
if file_path_display != "N/A" and len(file_path_display) > 50: if file_path_display != "N/A" and len(file_path_display) > 50:
file_path_display = "..." + file_path_display[-47:] file_path_display = "..." + file_path_display[-47:]
@@ -120,7 +120,7 @@ class PipeObject:
debug(f"│ Hash : {hash_display:<48}") debug(f"│ Hash : {hash_display:<48}")
debug(f"│ Store : {store_display:<48}") debug(f"│ Store : {store_display:<48}")
debug(f"│ Title : {title_display:<48}") debug(f"│ Title : {title_display:<48}")
debug(f"│ Tags : {tags_display:<48}") debug(f"│ Tag : {tag_display:<48}")
debug(f"│ URL : {url_display:<48}") debug(f"│ URL : {url_display:<48}")
debug(f"│ File Path : {file_path_display:<48}") debug(f"│ File Path : {file_path_display:<48}")
debug(f"│ Relationships: {relationships_display:<47}") debug(f"│ Relationships: {relationships_display:<47}")
@@ -164,8 +164,8 @@ class PipeObject:
"store": self.store, "store": self.store,
} }
if self.tags: if self.tag:
data["tags"] = self.tags data["tag"] = self.tag
if self.title: if self.title:
data["title"] = self.title data["title"] = self.title
if self.url: if self.url:
@@ -298,7 +298,7 @@ class DownloadMediaResult:
"""Result of a successful media download.""" """Result of a successful media download."""
path: Path path: Path
info: Dict[str, Any] info: Dict[str, Any]
tags: List[str] tag: List[str]
source_url: Optional[str] source_url: Optional[str]
hash_value: Optional[str] = None hash_value: Optional[str] = None
paths: Optional[List[Path]] = None # For multiple files (e.g., section downloads) paths: Optional[List[Path]] = None # For multiple files (e.g., section downloads)
@@ -677,7 +677,7 @@ class TUIResultCard:
subtitle: Optional[str] = None subtitle: Optional[str] = None
metadata: Optional[Dict[str, str]] = None metadata: Optional[Dict[str, str]] = None
media_kind: Optional[str] = None media_kind: Optional[str] = None
tags: Optional[List[str]] = None tag: Optional[List[str]] = None
file_hash: Optional[str] = None file_hash: Optional[str] = None
file_size: Optional[str] = None file_size: Optional[str] = None
duration: Optional[str] = None duration: Optional[str] = None
@@ -686,8 +686,8 @@ class TUIResultCard:
"""Initialize default values.""" """Initialize default values."""
if self.metadata is None: if self.metadata is None:
self.metadata = {} self.metadata = {}
if self.tags is None: if self.tag is None:
self.tags = [] self.tag = []
@dataclass @dataclass
+4 -4
View File
@@ -197,7 +197,7 @@ def store_value(key: str, value: Any) -> None:
def load_value(key: str, default: Any = None) -> Any: def load_value(key: str, default: Any = None) -> Any:
"""Retrieve a value stored by an earlier pipeline stage. """Retrieve a value stored by an earlier pipeline stage.
Supports dotted path notation for nested access (e.g., "metadata.tags" or "items.0"). Supports dotted path notation for nested access (e.g., "metadata.tag" or "items.0").
Args: Args:
key: Variable name or dotted path (e.g., "my_var", "metadata.title", "list.0") key: Variable name or dotted path (e.g., "my_var", "metadata.title", "list.0")
@@ -447,7 +447,7 @@ def set_last_result_table(result_table: Optional[Any], items: Optional[List[Any]
Also maintains a history stack for @.. navigation (restore previous result table). Also maintains a history stack for @.. navigation (restore previous result table).
Only selectable commands (search-file, download-data) should call this to create history. Only selectable commands (search-file, download-data) should call this to create history.
For action commands (delete-tag, add-tag, etc), use set_last_result_table_preserve_history() instead. For action commands (delete-tag, add-tags, etc), use set_last_result_table_preserve_history() instead.
Args: Args:
result_table: The ResultTable object that was displayed (or None) result_table: The ResultTable object that was displayed (or None)
@@ -524,7 +524,7 @@ def set_last_result_table_overlay(result_table: Optional[Any], items: Optional[L
def set_last_result_table_preserve_history(result_table: Optional[Any], items: Optional[List[Any]] = None, subject: Optional[Any] = None) -> None: def set_last_result_table_preserve_history(result_table: Optional[Any], items: Optional[List[Any]] = None, subject: Optional[Any] = None) -> None:
"""Update the last result table WITHOUT adding to history. """Update the last result table WITHOUT adding to history.
Used for action commands (delete-tag, add-tag, etc.) that modify data but shouldn't Used for action commands (delete-tag, add-tags, etc.) that modify data but shouldn't
create history entries. This allows @.. to navigate search results, not undo stacks. create history entries. This allows @.. to navigate search results, not undo stacks.
Args: Args:
@@ -543,7 +543,7 @@ def set_last_result_items_only(items: Optional[List[Any]]) -> None:
"""Store items for @N selection WITHOUT affecting history or saved search data. """Store items for @N selection WITHOUT affecting history or saved search data.
Used for display-only commands (get-tag, get-url, etc.) and action commands Used for display-only commands (get-tag, get-url, etc.) and action commands
(delete-tag, add-tag, etc.) that emit results but shouldn't affect history. (delete-tag, add-tags, etc.) that emit results but shouldn't affect history.
These items are available for @1, @2, etc. selection in the next command, These items are available for @1, @2, etc. selection in the next command,
but are NOT saved to history. This preserves search context for @.. navigation. but are NOT saved to history. This preserves search context for @.. navigation.
+26 -26
View File
@@ -79,7 +79,7 @@ class TUIResultCard:
subtitle: Optional[str] = None subtitle: Optional[str] = None
metadata: Optional[Dict[str, str]] = None metadata: Optional[Dict[str, str]] = None
media_kind: Optional[str] = None media_kind: Optional[str] = None
tags: Optional[List[str]] = None tag: Optional[List[str]] = None
file_hash: Optional[str] = None file_hash: Optional[str] = None
file_size: Optional[str] = None file_size: Optional[str] = None
duration: Optional[str] = None duration: Optional[str] = None
@@ -88,8 +88,8 @@ class TUIResultCard:
"""Initialize default values.""" """Initialize default values."""
if self.metadata is None: if self.metadata is None:
self.metadata = {} self.metadata = {}
if self.tags is None: if self.tag is None:
self.tags = [] self.tag = []
@dataclass @dataclass
@@ -164,7 +164,7 @@ class ResultTable:
>>> row = result_table.add_row() >>> row = result_table.add_row()
>>> row.add_column("File", "document.pdf") >>> row.add_column("File", "document.pdf")
>>> row.add_column("Size", "2.5 MB") >>> row.add_column("Size", "2.5 MB")
>>> row.add_column("Tags", "pdf, document") >>> row.add_column("Tag", "pdf, document")
>>> print(result_table) >>> print(result_table)
""" """
@@ -425,12 +425,12 @@ class ResultTable:
if hasattr(result, 'media_kind') and result.media_kind: if hasattr(result, 'media_kind') and result.media_kind:
row.add_column("Type", result.media_kind) row.add_column("Type", result.media_kind)
# Tags summary # Tag summary
if hasattr(result, 'tag_summary') and result.tag_summary: if hasattr(result, 'tag_summary') and result.tag_summary:
tags_str = str(result.tag_summary) tag_str = str(result.tag_summary)
if len(tags_str) > 60: if len(tag_str) > 60:
tags_str = tags_str[:57] + "..." tag_str = tag_str[:57] + "..."
row.add_column("Tags", tags_str) row.add_column("Tag", tag_str)
# Duration (for media) # Duration (for media)
if hasattr(result, 'duration_seconds') and result.duration_seconds: if hasattr(result, 'duration_seconds') and result.duration_seconds:
@@ -494,7 +494,7 @@ class ResultTable:
"""Extract and add TagItem fields to row (compact tag display). """Extract and add TagItem fields to row (compact tag display).
Shows the Tag column with the tag name and Source column to identify Shows the Tag column with the tag name and Source column to identify
which storage backend the tags come from (Hydrus, local, etc.). which storage backend the tag values come from (Hydrus, local, etc.).
All data preserved in TagItem for piping and operations. All data preserved in TagItem for piping and operations.
Use @1 to select a tag, @{1,3,5} to select multiple. Use @1 to select a tag, @{1,3,5} to select multiple.
""" """
@@ -505,7 +505,7 @@ class ResultTable:
tag_name = tag_name[:57] + "..." tag_name = tag_name[:57] + "..."
row.add_column("Tag", tag_name) row.add_column("Tag", tag_name)
# Source/Store (where the tags come from) # Source/Store (where the tag values come from)
if hasattr(item, 'source') and item.source: if hasattr(item, 'source') and item.source:
row.add_column("Store", item.source) row.add_column("Store", item.source)
@@ -527,12 +527,12 @@ class ResultTable:
file_str = "..." + file_str[-57:] file_str = "..." + file_str[-57:]
row.add_column("Path", file_str) row.add_column("Path", file_str)
# Tags # Tag
if hasattr(obj, 'tags') and obj.tags: if hasattr(obj, 'tag') and obj.tag:
tags_str = ", ".join(obj.tags[:3]) # First 3 tags tag_str = ", ".join(obj.tag[:3]) # First 3 tag values
if len(obj.tags) > 3: if len(obj.tag) > 3:
tags_str += f", +{len(obj.tags) - 3} more" tag_str += f", +{len(obj.tag) - 3} more"
row.add_column("Tags", tags_str) row.add_column("Tag", tag_str)
# Duration # Duration
if hasattr(obj, 'duration') and obj.duration: if hasattr(obj, 'duration') and obj.duration:
@@ -560,7 +560,7 @@ class ResultTable:
- 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 - tag | tag_summary
- detail | description - detail | description
""" """
# Helper to determine if a field should be hidden from display # Helper to determine if a field should be hidden from display
@@ -568,7 +568,7 @@ class ResultTable:
# Hide internal/metadata fields # Hide internal/metadata fields
hidden_fields = { hidden_fields = {
'__', 'id', 'action', 'parent_id', 'is_temp', 'path', 'extra', '__', 'id', 'action', 'parent_id', 'is_temp', 'path', 'extra',
'target', 'hash', 'hash_hex', 'file_hash', 'tags', 'tag_summary', 'name' 'target', 'hash', 'hash_hex', 'file_hash', 'tag', 'tag_summary', 'name'
} }
if isinstance(field_name, str): if isinstance(field_name, str):
if field_name.startswith('__'): if field_name.startswith('__'):
@@ -1220,12 +1220,12 @@ class ResultTable:
title = col.value title = col.value
metadata[col.name] = col.value metadata[col.name] = col.value
# Extract tags if present # Extract tag values if present
tags = [] tag = []
if "tags" in metadata: if "Tag" in metadata:
tags_val = metadata["tags"] tag_val = metadata["Tag"]
if tags_val: if tag_val:
tags = [t.strip() for t in tags_val.split(",")][:5] tag = [t.strip() for t in tag_val.split(",")][:5]
# Try to find useful metadata fields # Try to find useful metadata fields
subtitle = metadata.get("Artist", metadata.get("Author", "")) subtitle = metadata.get("Artist", metadata.get("Author", ""))
@@ -1239,7 +1239,7 @@ class ResultTable:
subtitle=subtitle, subtitle=subtitle,
metadata=metadata, metadata=metadata,
media_kind=media_kind, media_kind=media_kind,
tags=tags, tag=tag,
file_hash=file_hash or None, file_hash=file_hash or None,
file_size=file_size or None, file_size=file_size or None,
duration=duration or None duration=duration or None
+7 -7
View File
@@ -222,7 +222,7 @@ def create_app():
"path": str(file_path), "path": str(file_path),
"size": file_path.stat().st_size, "size": file_path.stat().st_size,
"metadata": metadata, "metadata": metadata,
"tags": tags "tag": tags
}), 200 }), 200
except Exception as e: except Exception as e:
logger.error(f"Get metadata error: {e}", exc_info=True) logger.error(f"Get metadata error: {e}", exc_info=True)
@@ -238,7 +238,7 @@ def create_app():
data = request.get_json() or {} data = request.get_json() or {}
file_path_str = data.get('path') file_path_str = data.get('path')
tags = data.get('tags', []) tags = data.get('tag', [])
url = data.get('url', []) url = data.get('url', [])
if not file_path_str: if not file_path_str:
@@ -289,7 +289,7 @@ def create_app():
return jsonify({"error": "File not found"}), 404 return jsonify({"error": "File not found"}), 404
tags = db.get_tags(file_path) tags = db.get_tags(file_path)
return jsonify({"hash": file_hash, "tags": tags}), 200 return jsonify({"hash": file_hash, "tag": tags}), 200
except Exception as e: except Exception as e:
logger.error(f"Get tags error: {e}", exc_info=True) logger.error(f"Get tags error: {e}", exc_info=True)
return jsonify({"error": f"Failed: {str(e)}"}), 500 return jsonify({"error": f"Failed: {str(e)}"}), 500
@@ -302,11 +302,11 @@ def create_app():
from API.folder import API_folder_store from API.folder import API_folder_store
data = request.get_json() or {} data = request.get_json() or {}
tags = data.get('tags', []) tags = data.get('tag', [])
mode = data.get('mode', 'add') mode = data.get('mode', 'add')
if not tags: if not tags:
return jsonify({"error": "Tags required"}), 400 return jsonify({"error": "Tag required"}), 400
try: try:
with API_folder_store(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
@@ -318,7 +318,7 @@ def create_app():
db.remove_tags(file_path, db.get_tags(file_path)) db.remove_tags(file_path, db.get_tags(file_path))
db.add_tags(file_path, tags) db.add_tags(file_path, tags)
return jsonify({"hash": file_hash, "tags_added": len(tags), "mode": mode}), 200 return jsonify({"hash": file_hash, "tag_added": len(tags), "mode": mode}), 200
except Exception as e: except Exception as e:
logger.error(f"Add tags error: {e}", exc_info=True) logger.error(f"Add tags error: {e}", exc_info=True)
return jsonify({"error": f"Failed: {str(e)}"}), 500 return jsonify({"error": f"Failed: {str(e)}"}), 500
@@ -330,7 +330,7 @@ def create_app():
"""Remove tags from a file.""" """Remove tags from a file."""
from API.folder import API_folder_store from API.folder import API_folder_store
tags_str = request.args.get('tags', '') tags_str = request.args.get('tag', '')
try: try:
with API_folder_store(STORAGE_PATH) as db: with API_folder_store(STORAGE_PATH) as db:
+12 -12
View File
@@ -1,4 +1,4 @@
"""Search-file cmdlet: Search for files by query, tags, size, type, duration, etc.""" """Search-file cmdlet: Search for files by query, tag, size, type, duration, etc."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, Sequence, List, Optional, Tuple from typing import Any, Dict, Sequence, List, Optional, Tuple
@@ -43,9 +43,9 @@ except Exception: # pragma: no cover
resolve_output_dir = None # type: ignore resolve_output_dir = None # type: ignore
try: try:
from API.HydrusNetwork import HydrusClient, HydrusRequestError from API.HydrusNetwork import HydrusNetwork, HydrusRequestError
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
HydrusClient = None # type: ignore HydrusNetwork = None # type: ignore
HydrusRequestError = RuntimeError # type: ignore HydrusRequestError = RuntimeError # type: ignore
try: try:
@@ -63,7 +63,7 @@ class SearchRecord:
path: str path: str
size_bytes: int | None = None size_bytes: int | None = None
duration_seconds: str | None = None duration_seconds: str | None = None
tags: str | None = None tag: str | None = None
hash: str | None = None hash: str | None = None
def as_dict(self) -> dict[str, str]: def as_dict(self) -> dict[str, str]:
@@ -72,8 +72,8 @@ class SearchRecord:
payload["size"] = str(self.size_bytes) payload["size"] = str(self.size_bytes)
if self.duration_seconds: if self.duration_seconds:
payload["duration"] = self.duration_seconds payload["duration"] = self.duration_seconds
if self.tags: if self.tag:
payload["tags"] = self.tags payload["tag"] = self.tag
if self.hash: if self.hash:
payload["hash"] = self.hash payload["hash"] = self.hash
return payload return payload
@@ -93,7 +93,7 @@ class ResultItem:
duration_seconds: Optional[float] = None duration_seconds: Optional[float] = None
size_bytes: Optional[int] = None size_bytes: Optional[int] = None
full_metadata: Optional[Dict[str, Any]] = None full_metadata: Optional[Dict[str, Any]] = None
tags: Optional[set[str]] = field(default_factory=set) tag: Optional[set[str]] = field(default_factory=set)
relationships: Optional[List[str]] = field(default_factory=list) relationships: Optional[List[str]] = field(default_factory=list)
known_urls: Optional[List[str]] = field(default_factory=list) known_urls: Optional[List[str]] = field(default_factory=list)
@@ -128,9 +128,9 @@ class ResultItem:
if self.hash: if self.hash:
payload["hash"] = self.hash payload["hash"] = self.hash
if self.tag_summary: if self.tag_summary:
payload["tags"] = self.tag_summary payload["tag_summary"] = self.tag_summary
if self.tags: if self.tag:
payload["tags_set"] = list(self.tags) payload["tag"] = list(self.tag)
if self.relationships: if self.relationships:
payload["relationships"] = self.relationships payload["relationships"] = self.relationships
if self.known_urls: if self.known_urls:
@@ -411,7 +411,7 @@ class Search_File(Cmdlet):
return 1 return 1
searched_backends.append(backend_to_search) searched_backends.append(backend_to_search)
target_backend = storage[backend_to_search] target_backend = storage[backend_to_search]
results = target_backend.search_store(query, limit=limit) results = target_backend.search(query, limit=limit)
else: else:
from API.HydrusNetwork import is_hydrus_available from API.HydrusNetwork import is_hydrus_available
hydrus_available = is_hydrus_available(config or {}) hydrus_available = is_hydrus_available(config or {})
@@ -422,7 +422,7 @@ class Search_File(Cmdlet):
continue continue
searched_backends.append(backend_name) searched_backends.append(backend_name)
try: try:
backend_results = storage[backend_name].search_store(query, limit=limit - len(all_results)) backend_results = storage[backend_name].search(query, limit=limit - len(all_results))
if backend_results: if backend_results:
all_results.extend(backend_results) all_results.extend(backend_results)
if len(all_results) >= limit: if len(all_results) >= limit: