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:
@@ -1,4 +1,5 @@
|
||||
"""Trim a media file using ffmpeg."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence, Optional
|
||||
@@ -30,9 +31,19 @@ CMDLET = Cmdlet(
|
||||
usage="trim-file [-path <path>] [-input <path-or-url>] -range <start-end> [-outdir <dir>] [-delete]",
|
||||
arg=[
|
||||
CmdletArg("-path", description="Path to the file (optional if piped)."),
|
||||
CmdletArg("-input", description="Override input media source (path or URL). Useful when piping store metadata but trimming from an mpv stream URL."),
|
||||
CmdletArg("-range", required=True, description="Time range to trim (e.g. '3:45-3:55', '00:03:45-00:03:55', or '1h3m-1h10m30s')."),
|
||||
CmdletArg("-outdir", description="Output directory for the clip (defaults to source folder for local files; otherwise uses config temp/videos)."),
|
||||
CmdletArg(
|
||||
"-input",
|
||||
description="Override input media source (path or URL). Useful when piping store metadata but trimming from an mpv stream URL.",
|
||||
),
|
||||
CmdletArg(
|
||||
"-range",
|
||||
required=True,
|
||||
description="Time range to trim (e.g. '3:45-3:55', '00:03:45-00:03:55', or '1h3m-1h10m30s').",
|
||||
),
|
||||
CmdletArg(
|
||||
"-outdir",
|
||||
description="Output directory for the clip (defaults to source folder for local files; otherwise uses config temp/videos).",
|
||||
),
|
||||
CmdletArg("-delete", type="flag", description="Delete the original file after trimming."),
|
||||
],
|
||||
detail=[
|
||||
@@ -41,7 +52,7 @@ CMDLET = Cmdlet(
|
||||
"Inherits tag values from the source file.",
|
||||
"Adds a relationship to the source file (if hash is available).",
|
||||
"Output can be piped to add-file.",
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -71,6 +82,7 @@ def _format_hms(total_seconds: float) -> str:
|
||||
return "0s"
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _is_url(value: str) -> bool:
|
||||
try:
|
||||
p = urlparse(str(value))
|
||||
@@ -88,7 +100,7 @@ def _parse_time(time_str: str) -> float:
|
||||
- SS(.sss)
|
||||
- 1h3m53s (also 1h3m, 3m53s, 53s)
|
||||
"""
|
||||
raw = str(time_str or '').strip()
|
||||
raw = str(time_str or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("Empty time")
|
||||
|
||||
@@ -97,15 +109,15 @@ def _parse_time(time_str: str) -> float:
|
||||
r"(?i)\s*(?:(?P<h>\d+(?:\.\d+)?)h)?(?:(?P<m>\d+(?:\.\d+)?)m)?(?:(?P<s>\d+(?:\.\d+)?)s)?\s*",
|
||||
raw,
|
||||
)
|
||||
if hms and (hms.group('h') or hms.group('m') or hms.group('s')):
|
||||
hours = float(hms.group('h') or 0)
|
||||
minutes = float(hms.group('m') or 0)
|
||||
seconds = float(hms.group('s') or 0)
|
||||
if hms and (hms.group("h") or hms.group("m") or hms.group("s")):
|
||||
hours = float(hms.group("h") or 0)
|
||||
minutes = float(hms.group("m") or 0)
|
||||
seconds = float(hms.group("s") or 0)
|
||||
total = hours * 3600 + minutes * 60 + seconds
|
||||
return float(total)
|
||||
|
||||
# Colon-separated
|
||||
parts = [p.strip() for p in raw.split(':')]
|
||||
parts = [p.strip() for p in raw.split(":")]
|
||||
if len(parts) == 3:
|
||||
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
|
||||
if len(parts) == 2:
|
||||
@@ -117,15 +129,15 @@ def _parse_time(time_str: str) -> float:
|
||||
|
||||
|
||||
def _sanitize_filename(name: str, *, max_len: int = 140) -> str:
|
||||
name = str(name or '').strip()
|
||||
name = str(name or "").strip()
|
||||
if not name:
|
||||
return 'clip'
|
||||
return "clip"
|
||||
# Windows-forbidden characters: <>:"/\\|?* plus control chars
|
||||
name = re.sub('[<>:"/\\\\|?*\\x00-\\x1F]', '_', name)
|
||||
name = re.sub('[<>:"/\\\\|?*\\x00-\\x1F]', "_", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
name = name.rstrip('.')
|
||||
name = name.rstrip(".")
|
||||
if not name:
|
||||
return 'clip'
|
||||
return "clip"
|
||||
if len(name) > max_len:
|
||||
name = name[:max_len].rstrip()
|
||||
return name
|
||||
@@ -140,7 +152,9 @@ def _extract_store_name(item: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _persist_alt_relationship(*, config: Dict[str, Any], store_name: str, alt_hash: str, king_hash: str) -> None:
|
||||
def _persist_alt_relationship(
|
||||
*, config: Dict[str, Any], store_name: str, alt_hash: str, king_hash: str
|
||||
) -> None:
|
||||
"""Persist directional alt -> king relationship in the given backend."""
|
||||
try:
|
||||
store = Store(config)
|
||||
@@ -155,7 +169,11 @@ def _persist_alt_relationship(*, config: Dict[str, Any], store_name: str, alt_ha
|
||||
|
||||
# Folder-backed local DB
|
||||
try:
|
||||
if type(backend).__name__ == "Folder" and hasattr(backend, "location") and callable(getattr(backend, "location")):
|
||||
if (
|
||||
type(backend).__name__ == "Folder"
|
||||
and hasattr(backend, "location")
|
||||
and callable(getattr(backend, "location"))
|
||||
):
|
||||
from API.folder import API_folder_store
|
||||
from pathlib import Path
|
||||
|
||||
@@ -174,12 +192,15 @@ def _persist_alt_relationship(*, config: Dict[str, Any], store_name: str, alt_ha
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _trim_media(input_source: str, output_path: Path, start_seconds: float, duration_seconds: float) -> bool:
|
||||
|
||||
def _trim_media(
|
||||
input_source: str, output_path: Path, start_seconds: float, duration_seconds: float
|
||||
) -> bool:
|
||||
"""Trim media using ffmpeg.
|
||||
|
||||
input_source may be a local path or a URL.
|
||||
"""
|
||||
ffmpeg_path = shutil.which('ffmpeg')
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if not ffmpeg_path:
|
||||
log("ffmpeg not found in PATH", file=sys.stderr)
|
||||
return False
|
||||
@@ -190,38 +211,45 @@ def _trim_media(input_source: str, output_path: Path, start_seconds: float, dura
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
ffmpeg_path, '-y',
|
||||
'-ss', str(float(start_seconds)),
|
||||
'-i', str(input_source),
|
||||
'-t', str(float(duration_seconds)),
|
||||
'-c', 'copy',
|
||||
'-map_metadata', '0',
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-ss",
|
||||
str(float(start_seconds)),
|
||||
"-i",
|
||||
str(input_source),
|
||||
"-t",
|
||||
str(float(duration_seconds)),
|
||||
"-c",
|
||||
"copy",
|
||||
"-map_metadata",
|
||||
"0",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
|
||||
debug(f"Running ffmpeg: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"ffmpeg error: {result.stderr}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"Error parsing time or running ffmpeg: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"""Trim a media file."""
|
||||
# Parse arguments
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
|
||||
|
||||
range_arg = parsed.get("range")
|
||||
if not range_arg or '-' not in range_arg:
|
||||
if not range_arg or "-" not in range_arg:
|
||||
log("Error: -range argument required (format: start-end)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
start_str, end_str = [s.strip() for s in range_arg.split('-', 1)]
|
||||
|
||||
start_str, end_str = [s.strip() for s in range_arg.split("-", 1)]
|
||||
if not start_str or not end_str:
|
||||
log("Error: -range must be start-end", file=sys.stderr)
|
||||
return 1
|
||||
@@ -237,25 +265,25 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if duration_seconds <= 0:
|
||||
log(f"Invalid range: start {start_str} >= end {end_str}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
delete_original = parsed.get("delete", False)
|
||||
path_arg = parsed.get("path")
|
||||
input_override = parsed.get("input")
|
||||
outdir_arg = parsed.get("outdir")
|
||||
|
||||
|
||||
# Collect inputs
|
||||
inputs = normalize_result_input(result)
|
||||
|
||||
|
||||
# If path arg provided, add it to inputs
|
||||
if path_arg:
|
||||
inputs.append({"path": path_arg})
|
||||
|
||||
|
||||
if not inputs:
|
||||
log("No input files provided.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
success_count = 0
|
||||
|
||||
|
||||
for item in inputs:
|
||||
store_name = _extract_store_name(item)
|
||||
|
||||
@@ -267,7 +295,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
file_path = item.path
|
||||
elif isinstance(item, str):
|
||||
file_path = item
|
||||
|
||||
|
||||
if not file_path and not input_override:
|
||||
continue
|
||||
|
||||
@@ -283,18 +311,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if not path_obj or not path_obj.exists():
|
||||
log(f"File not found: {media_source}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
|
||||
# Determine output directory
|
||||
output_dir: Path
|
||||
if outdir_arg:
|
||||
output_dir = Path(str(outdir_arg)).expanduser()
|
||||
elif store_name:
|
||||
from config import resolve_output_dir
|
||||
|
||||
output_dir = resolve_output_dir(config or {})
|
||||
elif path_obj is not None:
|
||||
output_dir = path_obj.parent
|
||||
else:
|
||||
from config import resolve_output_dir
|
||||
|
||||
output_dir = resolve_output_dir(config or {})
|
||||
|
||||
try:
|
||||
@@ -303,7 +333,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
pass
|
||||
|
||||
# Determine output filename
|
||||
output_ext = ''
|
||||
output_ext = ""
|
||||
if path_obj is not None:
|
||||
output_ext = path_obj.suffix
|
||||
base_name = path_obj.stem
|
||||
@@ -313,21 +343,21 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if title:
|
||||
base_name = _sanitize_filename(str(title))
|
||||
else:
|
||||
base_name = time.strftime('%Y%m%d-%H%M%S')
|
||||
base_name = time.strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
if base_name.lower().startswith('clip_'):
|
||||
if base_name.lower().startswith("clip_"):
|
||||
base_name = base_name[5:] or base_name
|
||||
|
||||
try:
|
||||
p = urlparse(str(media_source))
|
||||
last = (p.path or '').split('/')[-1]
|
||||
if last and '.' in last:
|
||||
output_ext = '.' + last.split('.')[-1]
|
||||
last = (p.path or "").split("/")[-1]
|
||||
if last and "." in last:
|
||||
output_ext = "." + last.split(".")[-1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not output_ext or len(output_ext) > 8:
|
||||
output_ext = '.mkv'
|
||||
output_ext = ".mkv"
|
||||
|
||||
new_filename = f"clip_{base_name}{output_ext}"
|
||||
output_path = output_dir / new_filename
|
||||
@@ -341,30 +371,30 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
if not candidate.exists():
|
||||
output_path = candidate
|
||||
break
|
||||
|
||||
|
||||
# Trim
|
||||
source_label = (path_obj.name if path_obj is not None else str(media_source))
|
||||
source_label = path_obj.name if path_obj is not None else str(media_source)
|
||||
log(f"Trimming {source_label} ({start_str} to {end_str})...", file=sys.stderr)
|
||||
if _trim_media(str(media_source), output_path, start_seconds, duration_seconds):
|
||||
log(f"Created clip: {output_path}", file=sys.stderr)
|
||||
success_count += 1
|
||||
|
||||
|
||||
# Prepare result for pipeline
|
||||
|
||||
|
||||
# 1. Get source hash for relationship
|
||||
source_hash = None
|
||||
if isinstance(item, dict):
|
||||
source_hash = item.get("hash")
|
||||
elif hasattr(item, "hash"):
|
||||
source_hash = item.hash
|
||||
|
||||
|
||||
if not source_hash:
|
||||
if path_obj is not None:
|
||||
try:
|
||||
source_hash = sha256_file(path_obj)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 2. Get tag values
|
||||
# Do not inherit tags from the source (per UX request).
|
||||
new_tags: list[str] = []
|
||||
@@ -382,7 +412,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
urls.append(src_u.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 3. Get title and modify it
|
||||
title = extract_title_from_result(item)
|
||||
if not title:
|
||||
@@ -390,7 +420,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
range_hms = f"{_format_hms(start_seconds)}-{_format_hms(end_seconds)}"
|
||||
new_title = f"[{range_hms}] - {title}"
|
||||
|
||||
|
||||
# 4. Calculate clip hash
|
||||
clip_hash = None
|
||||
try:
|
||||
@@ -449,20 +479,20 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
"title": new_title,
|
||||
"tag": new_tags,
|
||||
"url": urls,
|
||||
"media_kind": "video", # Assumption, or derive
|
||||
"hash": clip_hash, # Pass calculated hash
|
||||
"media_kind": "video", # Assumption, or derive
|
||||
"hash": clip_hash, # Pass calculated hash
|
||||
"store": stored_store,
|
||||
"relationships": {
|
||||
# Clip is an ALT of the source; store semantics are directional alt -> king.
|
||||
# Provide both keys so downstream (e.g. add-file) can persist relationships.
|
||||
"king": [source_hash] if source_hash else [],
|
||||
"alt": [clip_hash] if (source_hash and clip_hash) else [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Emit result
|
||||
ctx.emit(result_dict)
|
||||
|
||||
|
||||
# Delete original if requested
|
||||
if delete_original:
|
||||
try:
|
||||
@@ -473,11 +503,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Maybe leave that to user or cleanup cmdlet
|
||||
except Exception as e:
|
||||
log(f"Failed to delete original: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
else:
|
||||
failed_label = (path_obj.name if path_obj is not None else str(media_source))
|
||||
failed_label = path_obj.name if path_obj is not None else str(media_source)
|
||||
log(f"Failed to trim {failed_label}", file=sys.stderr)
|
||||
|
||||
|
||||
return 0 if success_count > 0 else 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user