df
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
@@ -118,14 +118,24 @@ def _compile_extract_template(template: str) -> tuple[re.Pattern[str], List[str]
|
||||
name_lower = raw_name.lower()
|
||||
is_last = idx == (len(matches) - 1)
|
||||
if is_last:
|
||||
parts.append(fr"(?P<{raw_name}>.+)")
|
||||
parts.append(rf"(?P<{raw_name}>.+)")
|
||||
else:
|
||||
# Heuristic: common numeric fields should capture full digit runs.
|
||||
# This avoids ambiguous splits like track='2', title='3 ...'.
|
||||
if name_lower in {"disk", "disc", "cd", "track", "trk", "episode", "ep", "season", "year"}:
|
||||
parts.append(fr"(?P<{raw_name}>\d+)")
|
||||
if name_lower in {
|
||||
"disk",
|
||||
"disc",
|
||||
"cd",
|
||||
"track",
|
||||
"trk",
|
||||
"episode",
|
||||
"ep",
|
||||
"season",
|
||||
"year",
|
||||
}:
|
||||
parts.append(rf"(?P<{raw_name}>\d+)")
|
||||
else:
|
||||
parts.append(fr"(?P<{raw_name}>.+?)")
|
||||
parts.append(rf"(?P<{raw_name}>.+?)")
|
||||
|
||||
last_end = m.end()
|
||||
|
||||
@@ -159,7 +169,9 @@ def _extract_tags_from_title(title_text: str, template: str) -> List[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _get_title_candidates_for_extraction(res: Any, existing_tags: Optional[List[str]] = None) -> List[str]:
|
||||
def _get_title_candidates_for_extraction(
|
||||
res: Any, existing_tags: Optional[List[str]] = None
|
||||
) -> List[str]:
|
||||
"""Return a list of possible title strings in priority order."""
|
||||
|
||||
candidates: List[str] = []
|
||||
@@ -197,7 +209,9 @@ def _get_title_candidates_for_extraction(res: Any, existing_tags: Optional[List[
|
||||
return candidates
|
||||
|
||||
|
||||
def _extract_tags_from_title_candidates(candidates: List[str], template: str) -> tuple[List[str], Optional[str]]:
|
||||
def _extract_tags_from_title_candidates(
|
||||
candidates: List[str], template: str
|
||||
) -> tuple[List[str], Optional[str]]:
|
||||
"""Try candidates in order; return (tags, matched_candidate)."""
|
||||
|
||||
for c in candidates:
|
||||
@@ -207,7 +221,9 @@ def _extract_tags_from_title_candidates(candidates: List[str], template: str) ->
|
||||
return [], None
|
||||
|
||||
|
||||
def _try_compile_extract_template(template: Optional[str]) -> tuple[Optional[re.Pattern[str]], Optional[str]]:
|
||||
def _try_compile_extract_template(
|
||||
template: Optional[str],
|
||||
) -> tuple[Optional[re.Pattern[str]], Optional[str]]:
|
||||
"""Compile template for debug; return (pattern, error_message)."""
|
||||
if template is None:
|
||||
return None, None
|
||||
@@ -387,7 +403,13 @@ def _refresh_result_table_title(
|
||||
pass
|
||||
|
||||
|
||||
def _refresh_tag_view(res: Any, target_hash: Optional[str], store_name: Optional[str], target_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."""
|
||||
try:
|
||||
from cmdlet import get as get_cmdlet # type: ignore
|
||||
@@ -421,7 +443,6 @@ def _refresh_tag_view(res: Any, target_hash: Optional[str], store_name: Optional
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Add_Tag(Cmdlet):
|
||||
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
|
||||
|
||||
@@ -429,16 +450,42 @@ class Add_Tag(Cmdlet):
|
||||
super().__init__(
|
||||
name="add-tag",
|
||||
summary="Add tag to a file in a store.",
|
||||
usage="add-tag -store <store> [-query \"hash:<sha256>\"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
|
||||
usage='add-tag -store <store> [-query "hash:<sha256>"] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]',
|
||||
arg=[
|
||||
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),
|
||||
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,
|
||||
),
|
||||
SharedArgs.QUERY,
|
||||
SharedArgs.STORE,
|
||||
CmdletArg("-extract", type="string", description="Extract tags from the item's title using a simple template with (field) placeholders. Example: -extract \"(artist) - (album) - (disk)-(track) (title)\" will add artist:, album:, disk:, track:, title: tags."),
|
||||
CmdletArg("--extract-debug", type="flag", description="Print debug info for -extract matching (matched title source and extracted tags)."),
|
||||
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(
|
||||
"-extract",
|
||||
type="string",
|
||||
description='Extract tags from the item\'s title using a simple template with (field) placeholders. Example: -extract "(artist) - (album) - (disk)-(track) (title)" will add artist:, album:, disk:, track:, title: tags.',
|
||||
),
|
||||
CmdletArg(
|
||||
"--extract-debug",
|
||||
type="flag",
|
||||
description="Print debug info for -extract matching (matched title source and extracted tags).",
|
||||
),
|
||||
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).",
|
||||
),
|
||||
],
|
||||
detail=[
|
||||
"- By default, only tag non-temporary files (from pipelines). Use --all to tag everything.",
|
||||
@@ -446,7 +493,7 @@ class Add_Tag(Cmdlet):
|
||||
"- If -query 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\"",
|
||||
'- 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)",
|
||||
@@ -484,7 +531,9 @@ class Add_Tag(Cmdlet):
|
||||
# @N | download-media | add-tag ... | add-file ...
|
||||
store_override = parsed.get("store")
|
||||
stage_ctx = ctx.get_stage_context()
|
||||
has_downstream = bool(stage_ctx is not None and not getattr(stage_ctx, "is_last_stage", False))
|
||||
has_downstream = bool(
|
||||
stage_ctx is not None and not getattr(stage_ctx, "is_last_stage", False)
|
||||
)
|
||||
|
||||
include_temp = bool(parsed.get("all", False))
|
||||
if has_downstream and not include_temp and not store_override:
|
||||
@@ -498,7 +547,10 @@ class Add_Tag(Cmdlet):
|
||||
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)
|
||||
log(
|
||||
"No valid files to tag (all results were temporary; use --all to include temporary files)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Get tag from arguments (or fallback to pipeline payload)
|
||||
@@ -512,13 +564,13 @@ class Add_Tag(Cmdlet):
|
||||
if not raw_tag and results and not extract_template:
|
||||
first = results[0]
|
||||
payload_tag = None
|
||||
|
||||
|
||||
# Try multiple tag lookup strategies in order
|
||||
tag_lookups = [
|
||||
lambda x: getattr(x, "tag", None),
|
||||
lambda x: x.get("tag") if isinstance(x, dict) else None,
|
||||
]
|
||||
|
||||
|
||||
for lookup in tag_lookups:
|
||||
try:
|
||||
payload_tag = lookup(first)
|
||||
@@ -526,7 +578,7 @@ class Add_Tag(Cmdlet):
|
||||
break
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
continue
|
||||
|
||||
|
||||
if payload_tag:
|
||||
if isinstance(payload_tag, str):
|
||||
raw_tag = [payload_tag]
|
||||
@@ -536,7 +588,7 @@ class Add_Tag(Cmdlet):
|
||||
# Handle -list argument (convert to {list} syntax)
|
||||
list_arg = parsed.get("list")
|
||||
if list_arg:
|
||||
for l in list_arg.split(','):
|
||||
for l in list_arg.split(","):
|
||||
l = l.strip()
|
||||
if l:
|
||||
raw_tag.append(f"{{{l}}}")
|
||||
@@ -590,46 +642,70 @@ class Add_Tag(Cmdlet):
|
||||
# treat add-tag as a pipeline mutation (carry tags forward for add-file) instead of a store write.
|
||||
if not store_override:
|
||||
store_name_str = str(store_name) if store_name is not None else ""
|
||||
local_mode_requested = (not store_name_str) or (store_name_str.upper() == "PATH") or (store_name_str.lower() == "local")
|
||||
is_known_backend = bool(store_name_str) and store_registry.is_available(store_name_str)
|
||||
local_mode_requested = (
|
||||
(not store_name_str)
|
||||
or (store_name_str.upper() == "PATH")
|
||||
or (store_name_str.lower() == "local")
|
||||
)
|
||||
is_known_backend = bool(store_name_str) and store_registry.is_available(
|
||||
store_name_str
|
||||
)
|
||||
|
||||
if local_mode_requested and raw_path:
|
||||
try:
|
||||
if Path(str(raw_path)).expanduser().exists():
|
||||
existing_tag_list = _extract_item_tags(res)
|
||||
existing_lower = {t.lower() for t in existing_tag_list if isinstance(t, str)}
|
||||
existing_lower = {
|
||||
t.lower() for t in existing_tag_list if isinstance(t, str)
|
||||
}
|
||||
|
||||
item_tag_to_add = list(tag_to_add)
|
||||
|
||||
if extract_template:
|
||||
candidates = _get_title_candidates_for_extraction(res, existing_tag_list)
|
||||
extracted, matched = _extract_tags_from_title_candidates(candidates, extract_template)
|
||||
candidates = _get_title_candidates_for_extraction(
|
||||
res, existing_tag_list
|
||||
)
|
||||
extracted, matched = _extract_tags_from_title_candidates(
|
||||
candidates, extract_template
|
||||
)
|
||||
if extracted:
|
||||
extract_matched_items += 1
|
||||
if extract_debug:
|
||||
log(f"[add_tag] extract matched: {matched!r} -> {extracted}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract matched: {matched!r} -> {extracted}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for new_tag in extracted:
|
||||
if new_tag.lower() not in existing_lower:
|
||||
item_tag_to_add.append(new_tag)
|
||||
else:
|
||||
extract_no_match_items += 1
|
||||
if extract_debug:
|
||||
rx_preview = extract_debug_rx.pattern if extract_debug_rx else "<uncompiled>"
|
||||
rx_preview = (
|
||||
extract_debug_rx.pattern
|
||||
if extract_debug_rx
|
||||
else "<uncompiled>"
|
||||
)
|
||||
cand_preview = "; ".join([repr(c) for c in candidates[:3]])
|
||||
log(f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview!r}. candidates: {cand_preview}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview!r}. candidates: {cand_preview}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(item_tag_to_add, "title", prefer="last")
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add, "title", prefer="last"
|
||||
)
|
||||
|
||||
if duplicate_arg:
|
||||
parts = str(duplicate_arg).split(':')
|
||||
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()]
|
||||
targets = [t.strip() for t in parts[1].split(",") if t.strip()]
|
||||
else:
|
||||
parts2 = str(duplicate_arg).split(',')
|
||||
parts2 = str(duplicate_arg).split(",")
|
||||
if len(parts2) > 1:
|
||||
source_ns = parts2[0]
|
||||
targets = [t.strip() for t in parts2[1:] if t.strip()]
|
||||
@@ -654,7 +730,10 @@ class Add_Tag(Cmdlet):
|
||||
continue
|
||||
ns_prefix = ns.lower() + ":"
|
||||
for t in existing_tag_list:
|
||||
if t.lower().startswith(ns_prefix) and t.lower() != new_tag.lower():
|
||||
if (
|
||||
t.lower().startswith(ns_prefix)
|
||||
and t.lower() != new_tag.lower()
|
||||
):
|
||||
removed_namespace_tag.append(t)
|
||||
removed_namespace_tag = sorted({t for t in removed_namespace_tag})
|
||||
|
||||
@@ -664,7 +743,9 @@ class Add_Tag(Cmdlet):
|
||||
if isinstance(t, str) and t.lower() not in existing_lower
|
||||
]
|
||||
|
||||
updated_tag_list = [t for t in existing_tag_list if t not in removed_namespace_tag]
|
||||
updated_tag_list = [
|
||||
t for t in existing_tag_list if t not in removed_namespace_tag
|
||||
]
|
||||
updated_tag_list.extend(actual_tag_to_add)
|
||||
|
||||
_set_item_tags(res, updated_tag_list)
|
||||
@@ -672,7 +753,9 @@ class Add_Tag(Cmdlet):
|
||||
_apply_title_to_result(res, final_title)
|
||||
|
||||
total_added += len(actual_tag_to_add)
|
||||
total_modified += 1 if (removed_namespace_tag or actual_tag_to_add) else 0
|
||||
total_modified += (
|
||||
1 if (removed_namespace_tag or actual_tag_to_add) else 0
|
||||
)
|
||||
|
||||
ctx.emit(res)
|
||||
continue
|
||||
@@ -680,14 +763,22 @@ class Add_Tag(Cmdlet):
|
||||
pass
|
||||
|
||||
if local_mode_requested:
|
||||
log("[add_tag] Error: Missing usable local path for tagging (or provide -store)", file=sys.stderr)
|
||||
log(
|
||||
"[add_tag] Error: Missing usable local path for tagging (or provide -store)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if store_name_str and not is_known_backend:
|
||||
log(f"[add_tag] Error: Unknown store '{store_name_str}'. Available: {store_registry.list_backends()}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] Error: Unknown store '{store_name_str}'. Available: {store_registry.list_backends()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
resolved_hash = normalize_hash(hash_override) if hash_override else normalize_hash(raw_hash)
|
||||
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))
|
||||
@@ -700,7 +791,10 @@ class Add_Tag(Cmdlet):
|
||||
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)
|
||||
log(
|
||||
"[add_tag] Warning: Item missing usable hash (and could not derive from path); skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
ctx.emit(res)
|
||||
continue
|
||||
|
||||
@@ -724,34 +818,44 @@ class Add_Tag(Cmdlet):
|
||||
|
||||
if extract_template:
|
||||
candidates2 = _get_title_candidates_for_extraction(res, existing_tag_list)
|
||||
extracted2, matched2 = _extract_tags_from_title_candidates(candidates2, extract_template)
|
||||
extracted2, matched2 = _extract_tags_from_title_candidates(
|
||||
candidates2, extract_template
|
||||
)
|
||||
if extracted2:
|
||||
extract_matched_items += 1
|
||||
if extract_debug:
|
||||
log(f"[add_tag] extract matched: {matched2!r} -> {extracted2}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract matched: {matched2!r} -> {extracted2}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for new_tag in extracted2:
|
||||
if new_tag.lower() not in existing_lower:
|
||||
item_tag_to_add.append(new_tag)
|
||||
else:
|
||||
extract_no_match_items += 1
|
||||
if extract_debug:
|
||||
rx_preview2 = extract_debug_rx.pattern if extract_debug_rx else "<uncompiled>"
|
||||
rx_preview2 = (
|
||||
extract_debug_rx.pattern if extract_debug_rx else "<uncompiled>"
|
||||
)
|
||||
cand_preview2 = "; ".join([repr(c) for c in candidates2[:3]])
|
||||
log(f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview2!r}. candidates: {cand_preview2}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract no match for template {extract_template!r}. regex: {rx_preview2!r}. candidates: {cand_preview2}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
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(':')
|
||||
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()]
|
||||
targets = [t.strip() for t in parts[1].split(",") if t.strip()]
|
||||
else:
|
||||
parts2 = str(duplicate_arg).split(',')
|
||||
parts2 = str(duplicate_arg).split(",")
|
||||
if len(parts2) > 1:
|
||||
source_ns = parts2[0]
|
||||
targets = [t.strip() for t in parts2[1:] if t.strip()]
|
||||
@@ -812,11 +916,17 @@ class Add_Tag(Cmdlet):
|
||||
)
|
||||
|
||||
if extract_template and extract_matched_items == 0:
|
||||
log(f"[add_tag] extract: no matches for template '{extract_template}' across {len(results)} item(s)", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract: no matches for template '{extract_template}' across {len(results)} item(s)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif extract_template and extract_no_match_items > 0 and extract_debug:
|
||||
log(f"[add_tag] extract: matched {extract_matched_items}, no-match {extract_no_match_items}", file=sys.stderr)
|
||||
log(
|
||||
f"[add_tag] extract: matched {extract_matched_items}, no-match {extract_no_match_items}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
CMDLET = Add_Tag()
|
||||
CMDLET = Add_Tag()
|
||||
|
||||
Reference in New Issue
Block a user