Files
Medios-Macina/cmdlet/file/add_storage.py
T

1057 lines
35 KiB
Python

"""Storage operations, duplicate checking, and emission for add-file."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from pathlib import Path
import sys
import shutil
import traceback
from SYS import models
from SYS import pipeline as ctx
from SYS.logger import log, debug, debug_panel
from SYS.pipeline_progress import PipelineProgress
from SYS.result_publication import publish_result_table, overlay_existing_result_table
from PluginCore.backend_registry import BackendRegistry
from .. import _shared as sh
# Import from add_core (safe: add_core defines these before importing this module)
from .add_core import (
Add_File,
_CommandDependencies,
)
# Import module-level function from add_tagging
from .add_tagging import _maybe_apply_florencevision_tags
get_field = sh.get_field
def _update_pipe_object_destination(
pipe_obj: models.PipeObject,
*,
hash_value: str,
store: str,
plugin: Optional[str] = None,
path: Optional[str],
tag: List[str],
title: Optional[str],
extra_updates: Optional[Dict[str, Any]] = None,
) -> None:
pipe_obj.hash = hash_value
pipe_obj.store = store
pipe_obj.plugin = plugin
pipe_obj.is_temp = False
pipe_obj.path = path
pipe_obj.tag = tag
if title:
pipe_obj.title = title
if isinstance(pipe_obj.extra, dict):
pipe_obj.extra.update(extra_updates or {})
else:
pipe_obj.extra = dict(extra_updates or {})
def _stop_live_progress_for_terminal_render() -> None:
try:
live_progress = ctx.get_live_progress()
except Exception:
live_progress = None
if live_progress is None:
return
try:
stage_ctx = ctx.get_stage_context()
pipe_idx = getattr(stage_ctx, "pipe_index", None)
if isinstance(pipe_idx, int):
live_progress.finish_pipe(int(pipe_idx), force_complete=True)
except Exception:
pass
try:
live_progress.stop()
except Exception:
pass
try:
if hasattr(ctx, "set_live_progress"):
ctx.set_live_progress(None)
except Exception:
pass
def _emit_pipe_object(pipe_obj: models.PipeObject) -> None:
payload = pipe_obj.to_dict()
ctx.emit(payload)
ctx.set_current_stage_table(None)
stage_ctx = ctx.get_stage_context()
is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
if not is_last:
return
try:
_stop_live_progress_for_terminal_render()
from .._shared import display_and_persist_items
display_and_persist_items([payload], title="Result", subject=payload)
except Exception:
pass
def _emit_storage_result(
payload: Dict[str, Any],
*,
overlay: bool = True,
emit: bool = True
) -> None:
"""Emit a storage-style result payload.
- Always emits the dict downstream (when in a pipeline).
- If this is the last stage (or not in a pipeline), prints a search-file-like table
and sets an overlay table/items for @N selection.
"""
if emit:
ctx.emit(payload)
stage_ctx = ctx.get_stage_context()
is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
if not is_last or not overlay:
return
try:
from SYS.result_table import Table
table = Table("Result")
table.add_result(payload)
publish_result_table(ctx, table, [payload], subject=payload, overlay=True)
except Exception:
try:
ctx.set_last_result_items_only([payload])
except Exception:
pass
def _try_emit_search_file_by_hash(
*,
instance: str,
hash_value: str,
config: Dict[str, Any]
) -> Optional[List[Any]]:
"""Run search-file for a single hash so the final table/payload is consistent."""
try:
from cmdlet.file.search import CMDLET as search_file_cmdlet
args = ["-instance", str(instance), f"hash:{str(hash_value)}"]
prev_ctx = ctx.get_stage_context()
temp_ctx = ctx.PipelineStageContext(
stage_index=0,
total_stages=1,
pipe_index=0,
worker_id=getattr(prev_ctx, "worker_id", None),
)
ctx.set_stage_context(temp_ctx)
try:
code = search_file_cmdlet.run(None, args, config)
emitted_items = list(getattr(temp_ctx, "emits", []) or [])
finally:
ctx.set_stage_context(prev_ctx)
if code != 0:
return None
stage_ctx = ctx.get_stage_context()
is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
if is_last:
try:
overlay_existing_result_table(
ctx,
subject={
"store": instance,
"hash": hash_value
},
)
except Exception:
pass
return emitted_items
except Exception as exc:
debug(
f"[add-file] Failed to run search-file after add-file: {type(exc).__name__}: {exc}"
)
return None
def _try_emit_search_file_by_hashes(
*,
instance: str,
hash_values: List[str],
config: Dict[str, Any],
store_instance: Optional[BackendRegistry] = None,
) -> Optional[List[Any]]:
"""Run search-file for a list of hashes and promote the table to a display overlay.
Returns the emitted search-file payload items on success, else None.
"""
hashes = [h for h in (hash_values or []) if isinstance(h, str) and len(h) == 64]
if not instance or not hashes:
return None
try:
from cmdlet.file.search import CMDLET as search_file_cmdlet
query = "hash:" + ",".join(hashes)
args = ["-instance", str(instance), "-internal-refresh", query]
debug(f'[add-file] Refresh: search-file -instance {instance} "{query}"')
prev_ctx = ctx.get_stage_context()
temp_ctx = ctx.PipelineStageContext(
stage_index=0,
total_stages=1,
pipe_index=0,
worker_id=getattr(prev_ctx, "worker_id", None),
)
ctx.set_stage_context(temp_ctx)
try:
code = search_file_cmdlet.run(None, args, config)
emitted_items = list(getattr(temp_ctx, "emits", []) or [])
finally:
ctx.set_stage_context(prev_ctx)
if code != 0:
return None
stage_ctx = ctx.get_stage_context()
is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
if is_last:
try:
table = ctx.get_last_result_table()
items = ctx.get_last_result_items()
if table is not None and items:
if len(items) == 1:
try:
from SYS.rich_display import render_item_details_panel
render_item_details_panel(items[0])
setattr(table, "_rendered_by_cmdlet", True)
except Exception as exc:
debug(f"[add-file] Item details render failed: {exc}")
publish_result_table(
ctx,
table,
items,
subject={
"store": instance,
"hash": hashes
},
overlay=True,
)
except Exception:
pass
return emitted_items
except Exception as exc:
debug(
f"[add-file] Failed to run search-file after add-file: {type(exc).__name__}: {exc}"
)
return None
def _find_existing_hash_by_urls(
backend: Any,
urls: Sequence[str],
) -> Optional[str]:
"""Best-effort duplicate detection by URL before ingesting file bytes."""
url_candidates: List[str] = []
for raw in urls or []:
text = str(raw or "").strip()
if not text or not Add_File._is_probable_url(text):
continue
if text not in url_candidates:
url_candidates.append(text)
if not url_candidates:
return None
lookup_exact = getattr(backend, "find_hashes_by_url", None)
if callable(lookup_exact):
for candidate_url in url_candidates:
try:
hashes = lookup_exact(candidate_url) or []
except Exception:
continue
if not isinstance(hashes, (list, tuple, set)):
continue
for item in hashes:
normalized = Add_File._normalize_hash_candidate(item)
if normalized:
return normalized
searcher = getattr(backend, "search", None)
if callable(searcher):
for candidate_url in url_candidates:
try:
hits = searcher(f"url:{candidate_url}", limit=1, minimal=True) or []
except Exception:
continue
if not isinstance(hits, list) or not hits:
continue
hit = hits[0]
for key in ("hash", "file_hash", "sha256"):
normalized = Add_File._normalize_hash_candidate(get_field(hit, key))
if normalized:
return normalized
return None
def _emit_plugin_upload_payload(
upload_payload: Dict[str, Any],
plugin_name: str,
instance_name: Optional[str],
pipe_obj: models.PipeObject,
media_path: Path,
delete_after: bool,
) -> int:
payload = dict(upload_payload or {})
extra_updates: Dict[str, Any] = {}
raw_extra = payload.get("extra")
if isinstance(raw_extra, dict):
extra_updates.update(raw_extra)
if plugin_name:
extra_updates.setdefault("plugin", plugin_name)
if instance_name:
extra_updates.setdefault("instance", instance_name)
raw_urls = payload.get("url")
if isinstance(raw_urls, str):
url_values = [raw_urls.strip()] if raw_urls.strip() else []
extra_updates["url"] = url_values
elif isinstance(raw_urls, (list, tuple, set)):
url_values = [str(item).strip() for item in raw_urls if str(item).strip()]
extra_updates["url"] = url_values
relationships = payload.get("relationships")
if relationships:
try:
pipe_obj.relationships = relationships
except Exception:
pass
tags = payload.get("tag")
if isinstance(tags, list):
tag_values = [str(tag) for tag in tags]
else:
tag_values = list(pipe_obj.tag or [])
title_value = str(payload.get("title") or pipe_obj.title or media_path.name).strip() or media_path.name
path_value = str(payload.get("path") or pipe_obj.path or media_path).strip()
hash_value = str(
payload.get("hash")
or payload.get("file_hash")
or getattr(pipe_obj, "hash", None)
or "unknown"
).strip() or "unknown"
store_value = str(payload.get("store") or "").strip()
plugin_value = payload.get("plugin")
if plugin_value is None and plugin_name:
plugin_value = plugin_name
_update_pipe_object_destination(
pipe_obj,
hash_value=hash_value,
store=store_value,
plugin=str(plugin_value) if plugin_value else None,
path=path_value,
tag=tag_values,
title=title_value,
extra_updates=extra_updates,
)
_emit_pipe_object(pipe_obj)
_cleanup_after_success(media_path, delete_source=delete_after)
return 0
def _handle_plugin_upload(
media_path: Path,
plugin_name: str,
instance_name: Optional[str],
pipe_obj: models.PipeObject,
config: Dict[str, Any],
delete_after: bool,
folder_name: Optional[str] = None,
) -> int:
"""Handle uploading via an add-file plugin (e.g. 0x0)."""
from PluginCore.registry import (
PLUGIN_REGISTRY,
get_plugin_for_cmdlet,
list_plugins_for_cmdlet,
)
try:
file_provider = get_plugin_for_cmdlet(plugin_name, "add-file", config)
if not file_provider:
available_map = list_plugins_for_cmdlet("add-file", config)
available_add_plugins = [n for n, e in available_map.items() if e]
requested_plugin_info = PLUGIN_REGISTRY.get(plugin_name)
if requested_plugin_info is not None and "add-file" in requested_plugin_info.supported_cmdlets:
from SYS.rich_display import show_plugin_config_panel
show_plugin_config_panel([requested_plugin_info.canonical_name])
else:
log(f"Add-file plugin '{plugin_name}' is not available or does not support add-file", file=sys.stderr)
if available_add_plugins:
from SYS.rich_display import show_available_plugins_panel
show_available_plugins_panel(sorted(available_add_plugins))
return 1
upload_kwargs: Dict[str, Any] = {
"pipe_obj": pipe_obj,
"instance": instance_name,
}
pipeline_progress = PipelineProgress(ctx)
normalized_plugin_name = Add_File._normalize_provider_key(plugin_name)
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
if normalized_plugin_name == "local":
result = None
tags, urls, title, f_hash = Add_File._prepare_metadata(result, media_path, pipe_obj, config)
relationships = Add_File._get_relationships(result, pipe_obj)
direct_export_download = False
try:
if isinstance(pipe_obj.extra, dict):
direct_export_download = bool(pipe_obj.extra.pop("_direct_export_download", False))
except Exception:
direct_export_download = False
upload_kwargs.update(
{
"title": title,
"tags": tags,
"urls": urls,
"hash_value": f_hash,
"relationships": relationships,
"direct_export_download": direct_export_download,
"pipeline_progress": pipeline_progress,
}
)
if folder_name is not None and str(folder_name).strip():
upload_kwargs["folder_name"] = str(folder_name).strip()
upload_result = file_provider.upload(
str(media_path),
**upload_kwargs,
)
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
try:
if isinstance(getattr(pipe_obj, "extra", None), dict):
duplicate_upload = bool(pipe_obj.extra.get("upload_duplicate"))
duplicate_rule = str(pipe_obj.extra.get("upload_duplicate_rule") or "").strip()
duplicate_target = str(pipe_obj.extra.get("upload_duplicate_target") or "").strip()
except Exception:
duplicate_upload = False
duplicate_rule = ""
duplicate_target = ""
except Exception as exc:
log(f"Upload failed: {exc}", file=sys.stderr)
return 1
if isinstance(upload_result, dict):
return _emit_plugin_upload_payload(
upload_result,
plugin_name,
instance_name,
pipe_obj,
media_path,
delete_after,
)
hoster_url = str(upload_result or "").strip()
extra_updates: Dict[str, Any] = {
"plugin": plugin_name,
"instance": instance_name,
"plugin_url": hoster_url,
}
if isinstance(pipe_obj.extra, dict):
existing_known = list(pipe_obj.extra.get("url") or [])
if hoster_url and hoster_url not in existing_known:
existing_known.append(hoster_url)
extra_updates["url"] = existing_known
file_path = pipe_obj.path or (str(media_path) if media_path else None) or ""
_update_pipe_object_destination(
pipe_obj,
hash_value=f_hash or "unknown",
store="",
plugin=plugin_name or None,
path=file_path,
tag=pipe_obj.tag,
title=pipe_obj.title or (media_path.name if media_path else None),
extra_updates=extra_updates,
)
_emit_pipe_object(pipe_obj)
_cleanup_after_success(media_path, delete_source=delete_after)
return 0
def _handle_storage_backend(
result: Any,
media_path: Path,
backend_name: str,
pipe_obj: models.PipeObject,
config: Dict[str, Any],
delete_after: bool,
*,
collect_payloads: Optional[List[Dict[str, Any]]] = None,
collect_relationship_pairs: Optional[Dict[str, set[tuple[str, str]]]] = None,
defer_url_association: bool = False,
pending_url_associations: Optional[Dict[str, List[tuple[str, List[str]]]]] = None,
defer_tag_association: bool = False,
pending_tag_associations: Optional[Dict[str, List[tuple[str, List[str]]]]] = None,
suppress_last_stage_overlay: bool = False,
auto_search_file: bool = True,
store_instance: Optional[BackendRegistry] = None,
) -> int:
"""Handle uploading to a registered storage backend (e.g., 'test' folder store, 'hydrus', etc.)."""
pipeline_progress = PipelineProgress(ctx)
def _set_status(text: str) -> None:
try:
pipeline_progress.set_status(f"{backend_name}: {text}")
except Exception:
pass
def _clear_status() -> None:
try:
pipeline_progress.clear_status()
except Exception:
pass
delete_after_effective = bool(delete_after)
if not delete_after_effective:
try:
if (str(backend_name or "").strip().lower() != "temp"
and getattr(pipe_obj, "is_temp", False)
and getattr(pipe_obj, "action", None) == "cmdlet:download-media"):
from SYS.config import resolve_output_dir
temp_dir = resolve_output_dir(config)
try:
if media_path.resolve().is_relative_to(
temp_dir.expanduser().resolve()):
delete_after_effective = True
debug(
f"[add-file] Auto-delete temp source after ingest: {media_path}"
)
except Exception:
pass
except Exception:
pass
try:
backend_registry = store_instance if store_instance is not None else BackendRegistry(config)
backend, backend_registry, backend_exc = sh.get_preferred_store_backend(
config,
backend_name,
store_registry=backend_registry,
suppress_debug=True,
)
if backend is None:
raise backend_exc or KeyError(f"Unknown store backend: {backend_name}")
is_remote_backend = getattr(backend, "is_remote", False)
prefer_defer_tags = getattr(backend, "prefer_defer_tags", False)
supports_url_association = bool(getattr(backend, "supports_url_association", False))
supports_note_association = bool(getattr(backend, "supports_note_association", False))
supports_relationship_association = bool(getattr(backend, "supports_relationship_association", False))
tags, url, title, f_hash = Add_File._prepare_metadata(
result, media_path, pipe_obj, config
)
try:
from SYS.metadata import normalize_urls
source_store = None
source_hash = None
if isinstance(result, dict):
source_store = result.get("store")
source_hash = result.get("hash")
if not source_store:
source_store = getattr(pipe_obj, "store", None)
if not source_hash:
source_hash = getattr(pipe_obj, "hash", None)
if (not source_hash) and isinstance(pipe_obj.extra, dict):
source_hash = pipe_obj.extra.get("hash")
source_store = str(source_store or "").strip()
source_hash = str(source_hash or "").strip().lower()
if (source_store and source_hash and len(source_hash) == 64
and source_store.lower() != str(backend_name or "").strip().lower()):
source_backend = None
try:
store = backend_registry
if source_store in store.list_backends():
source_backend = store[source_store]
except Exception:
source_backend = None
if source_backend is not None:
try:
src_urls = normalize_urls(
source_backend.get_url(source_hash) or []
)
except Exception:
src_urls = []
try:
dst_urls = normalize_urls(url or [])
except Exception:
dst_urls = []
merged: list[str] = []
seen: set[str] = set()
for u in list(dst_urls or []) + list(src_urls or []):
if not u:
continue
if u in seen:
continue
seen.add(u)
merged.append(u)
url = merged
except Exception:
pass
if collect_relationship_pairs is not None and supports_relationship_association:
rels = Add_File._get_relationships(result, pipe_obj)
if isinstance(rels, dict) and rels:
king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels)
if king_hash and alt_hashes:
bucket = collect_relationship_pairs.setdefault(
str(backend_name),
set()
)
for alt_hash in alt_hashes:
if alt_hash and alt_hash != king_hash:
bucket.add((alt_hash, king_hash))
if isinstance(tags, list) and tags:
tags = [
t for t in tags if not (
isinstance(t, str)
and t.strip().lower().startswith("relationship:")
)
]
try:
tags = _maybe_apply_florencevision_tags(media_path, list(tags or []), config, pipe_obj=pipe_obj)
pipe_obj.tag = list(tags or [])
except Exception as exc:
log(f"[add-file] FlorenceVision tagging error: {exc}", file=sys.stderr)
return 1
upload_tags = tags
if prefer_defer_tags and upload_tags:
upload_tags = []
try:
debug_panel(
"add-file store",
[
("backend", backend_name),
("path", media_path),
("title", title),
("hash_hint", f_hash[:12] if f_hash else "N/A"),
("defer_tags", bool(prefer_defer_tags and tags)),
],
border_style="yellow",
)
except Exception:
pass
duplicate_hash = Add_File._find_existing_hash_by_urls(backend, url)
if duplicate_hash:
debug(
f"[add-file] URL duplicate detected in '{backend_name}', skipping upload and reusing hash {duplicate_hash[:12]}..."
)
file_identifier = duplicate_hash
else:
file_identifier = backend.add_file(
media_path,
title=title,
tag=upload_tags,
url=[] if ((defer_url_association and url) or (not supports_url_association)) else url,
file_hash=f_hash,
pipeline_progress=pipeline_progress,
transfer_label=title or media_path.name,
)
stored_path: Optional[str] = None
try:
if not is_remote_backend:
maybe_path = backend.get_file(file_identifier)
if isinstance(maybe_path, Path):
stored_path = str(maybe_path)
elif isinstance(maybe_path, str) and maybe_path:
stored_path = maybe_path
except Exception:
stored_path = None
if isinstance(file_identifier, str) and len(file_identifier) == 64:
chosen_hash = file_identifier
else:
chosen_hash = f_hash or (str(file_identifier) if file_identifier is not None else "unknown")
_update_pipe_object_destination(
pipe_obj,
hash_value=chosen_hash,
store=backend_name,
path=stored_path,
tag=tags,
title=title or pipe_obj.title or media_path.name,
extra_updates={
"url": url,
},
)
resolved_hash = chosen_hash
if prefer_defer_tags and tags:
if defer_tag_association and pending_tag_associations is not None:
try:
pending_tag_associations.setdefault(str(backend_name), []).append((str(resolved_hash), list(tags)))
except Exception:
pass
else:
try:
adder = getattr(backend, "add_tag", None)
if callable(adder):
_set_status("applying deferred tags")
adder(resolved_hash, list(tags))
except Exception as exc:
log(f"[add-file] Post-upload tagging failed for {backend_name}: {exc}", file=sys.stderr)
if url and supports_url_association:
if defer_url_association and pending_url_associations is not None:
try:
pending_url_associations.setdefault(
str(backend_name),
[]
).append((str(resolved_hash), list(url)))
except Exception:
pass
else:
try:
is_folder_backend = getattr(backend, "STORE_TYPE", "") == "folder"
if not is_folder_backend:
_set_status("associating urls")
backend.add_url(resolved_hash, list(url))
except Exception:
pass
def _write_note(note_name: str, note_text: Optional[str]) -> None:
if not note_text or not supports_note_association:
return
try:
setter = getattr(backend, "set_note", None)
if callable(setter):
_set_status(f"writing {note_name} note")
setter(resolved_hash, note_name, note_text)
except Exception as exc:
debug_panel(
"add-file note write failed",
[
("store", backend_name),
("hash", resolved_hash),
("note", note_name),
("error", exc),
],
border_style="yellow",
)
_write_note("sub", Add_File._get_note_text(result, pipe_obj, "sub"))
_write_note("lyric", Add_File._get_note_text(result, pipe_obj, "lyric"))
_write_note("chapters", Add_File._get_note_text(result, pipe_obj, "chapters"))
_write_note("caption", Add_File._get_note_text(result, pipe_obj, "caption"))
meta: Dict[str, Any] = {}
try:
is_folder_backend_meta = getattr(backend, "STORE_TYPE", "") == "folder"
if not is_folder_backend_meta:
_set_status("loading stored metadata")
meta = backend.get_metadata(resolved_hash) or {}
except Exception:
meta = {}
size_bytes: Optional[int] = None
for key in ("size_bytes", "size", "filesize", "file_size"):
try:
raw_size = meta.get(key)
if raw_size is not None:
size_bytes = int(raw_size)
break
except Exception:
pass
if size_bytes is None:
try:
size_bytes = int(media_path.stat().st_size)
except Exception:
size_bytes = None
title_out = (
meta.get("title") or title or pipe_obj.title or media_path.stem
or media_path.name
)
ext_out = meta.get("ext") or media_path.suffix.lstrip(".")
payload: Dict[str, Any] = {
"title": title_out,
"ext": str(ext_out or ""),
"size_bytes": size_bytes,
"store": backend_name,
"hash": resolved_hash,
"path": stored_path,
"tag": list(tags or []),
"url": list(url or []),
}
if collect_payloads is not None:
try:
collect_payloads.append(payload)
except Exception:
pass
if auto_search_file and resolved_hash and resolved_hash != "unknown":
_emit_storage_result(
payload,
overlay=not suppress_last_stage_overlay,
emit=False
)
refreshed_items = _try_emit_search_file_by_hash(
instance=backend_name,
hash_value=resolved_hash,
config=config,
)
if refreshed_items:
for emitted in refreshed_items:
ctx.emit(emitted)
else:
ctx.emit(payload)
else:
_emit_storage_result(
payload,
overlay=not suppress_last_stage_overlay,
emit=True
)
_cleanup_after_success(
media_path,
delete_source=delete_after_effective
)
_clear_status()
return 0
except Exception as exc:
_clear_status()
log(
f"❌ Failed to add file to backend '{backend_name}': {exc}",
file=sys.stderr
)
traceback.print_exc(file=sys.stderr)
return 1
def _apply_pending_url_associations(
pending: Dict[str, List[tuple[str, List[str]]]],
config: Dict[str, Any],
store_instance: Optional[BackendRegistry] = None,
) -> None:
"""Apply deferred URL associations in bulk, grouped per backend."""
try:
backend_registry = store_instance if store_instance is not None else BackendRegistry(config)
except Exception:
return
for backend_name, pairs in (pending or {}).items():
if not pairs:
continue
try:
backend, backend_registry, _exc = sh.get_store_backend(
config,
backend_name,
store_registry=backend_registry,
)
if backend is None:
continue
if not bool(getattr(backend, "supports_url_association", False)):
continue
items = sh.coalesce_hash_value_pairs(pairs)
if not items:
continue
bulk = getattr(backend, "add_url_bulk", None)
if callable(bulk):
try:
bulk(items)
continue
except Exception:
pass
single = getattr(backend, "add_url", None)
if callable(single):
for h, u in items:
try:
single(h, u)
except Exception:
continue
except Exception:
continue
def _apply_pending_tag_associations(
pending: Dict[str, List[tuple[str, List[str]]]],
config: Dict[str, Any],
store_instance: Optional[BackendRegistry] = None,
) -> None:
"""Apply deferred tag associations in bulk, grouped per backend."""
try:
backend_registry = store_instance if store_instance is not None else BackendRegistry(config)
except Exception:
return
sh.run_store_hash_value_batches(
config,
pending or {},
bulk_method_name="add_tags_bulk",
single_method_name="add_tag",
store_registry=backend_registry,
pass_config_to_bulk=False,
pass_config_to_single=False,
)
def _apply_pending_relationships(
pending: Dict[str, set[tuple[str, str]]],
config: Dict[str, Any],
store_instance: Optional[BackendRegistry] = None,
deps: Optional[_CommandDependencies] = None,
) -> None:
"""Persist relationships to backends that support relationships.
This delegates to an optional backend method: `set_relationship(alt, king, kind)`.
"""
if not pending:
return
if deps is None:
deps = _CommandDependencies(config)
try:
backend_registry = store_instance if store_instance is not None else deps.get_backend_registry()
except Exception:
return
for backend_name, pairs in pending.items():
if not pairs:
continue
try:
backend = backend_registry[str(backend_name)]
except Exception:
continue
if not bool(getattr(backend, "supports_relationship_association", False)):
continue
setter = getattr(backend, "set_relationship", None)
if not callable(setter):
continue
processed_pairs: set[tuple[str, str]] = set()
for alt_hash, king_hash in sorted(pairs):
if not alt_hash or not king_hash or alt_hash == king_hash:
continue
if (alt_hash, king_hash) in processed_pairs:
continue
alt_norm = str(alt_hash).strip().lower()
king_norm = str(king_hash).strip().lower()
if len(alt_norm) != 64 or len(king_norm) != 64:
continue
try:
setter(alt_norm, king_norm, "alt")
processed_pairs.add((alt_hash, king_hash))
except Exception:
continue
def _cleanup_after_success(media_path: Path, delete_source: bool):
is_temp_merge = "(merged)" in media_path.name or ".dlhx_" in media_path.name
if not delete_source and not is_temp_merge:
return
try:
media_path.unlink()
_cleanup_sidecar_files(media_path)
except Exception as exc:
log(f"⚠️ Could not delete file: {exc}", file=sys.stderr)
def _cleanup_sidecar_files(media_path: Path):
targets = [
media_path.parent / (media_path.name + ".metadata"),
media_path.parent / (media_path.name + ".notes"),
media_path.parent / (media_path.name + ".tag"),
]
for target in targets:
try:
if target.exists():
target.unlink()
except Exception:
pass
def _copy_sidecars(source_path: Path, target_path: Path):
possible_sidecars = [
source_path.with_suffix(source_path.suffix + ".json"),
source_path.with_name(source_path.name + ".tag"),
source_path.with_name(source_path.name + ".metadata"),
source_path.with_name(source_path.name + ".notes"),
]
for sc in possible_sidecars:
try:
if sc.exists():
suffix_part = sc.name.replace(source_path.name, "", 1)
dest_sidecar = target_path.parent / f"{target_path.name}{suffix_part}"
dest_sidecar.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(sc), dest_sidecar)
except Exception:
pass
def _persist_local_metadata(
library_root: Path,
dest_path: Path,
tags: List[str],
url: List[str],
f_hash: Optional[str],
relationships: Any,
duration: Any,
media_kind: str,
):
pass