Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# media-mate

[![Version](https://img.shields.io/badge/version-0.2.1-blue)](https://github.com/dspury/media-mate)
[![Version](https://img.shields.io/badge/version-0.3.0-blue)](https://github.com/dspury/media-mate)
[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/dspury/media-mate/ci.yml?style=flat-square)](https://github.com/dspury/media-mate/actions/workflows/ci.yml)
Expand Down
357 changes: 283 additions & 74 deletions SPEC.md

Large diffs are not rendered by default.

684 changes: 684 additions & 0 deletions SPEC_v0.2.2.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/media_mate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""media-mate — Zero-cost CLI for post-production media ops."""

__version__ = "0.2.1"
__version__ = "0.3.0"
__all__ = ["__version__"]
33 changes: 29 additions & 4 deletions src/media_mate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,22 @@ def probe(ctx: click.Context, path: Path) -> None:
default=False,
help="Move files instead of copying (raw folder is left intact by default).",
)
@click.option(
"--dry-run",
"dry_run",
is_flag=True,
default=False,
help="Show what would be organized without moving or copying any files.",
)
@click.pass_context
def organize(ctx: click.Context, path: Path, root: Path, do_move: bool) -> None:
def organize(ctx: click.Context, path: Path, root: Path, do_move: bool, dry_run: bool) -> None:
"""Organize media files into a structured folder layout.

Sources are copied by default; pass --move to relocate them.
"""
store = _get_store(ctx)
cfg = _get_config(ctx)
result = organize_path(path, root, store, config=cfg, move=do_move or None)
result = organize_path(path, root, store, config=cfg, move=do_move or None, dry_run=dry_run)

console = Console()
if result.files_moved == 0 and result.files_skipped == 0:
Expand All @@ -154,12 +161,18 @@ def organize(ctx: click.Context, path: Path, root: Path, do_move: bool) -> None:
f"[yellow]skipped {result.files_skipped}[/yellow], "
f"{result.bytes_moved:,} bytes total"
)
if result.dry_run:
console.print("[dim](dry run — no files were actually moved)[/dim]")
if result.errors:
console.print("[red]Errors:[/red]")
for err in result.errors[:5]:
console.print(f" {err}")
if len(result.errors) > 5:
console.print(f" ... and {len(result.errors) - 5} more")
if result.span_warnings:
console.print("[yellow]Spanned clip warnings:[/yellow]")
for w in result.span_warnings:
console.print(f" {w}")


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -187,6 +200,8 @@ def proxy(ctx: click.Context, path: Path, output_dir: Path) -> None:
console.print(f"[green]Generated {len(batch.results)} proxy file(s)[/green]")
if batch.skipped:
console.print(f"[dim]Skipped {len(batch.skipped)} non-video file(s)[/dim]")
if batch.already_existed:
console.print(f"[dim]Already existed {len(batch.already_existed)} file(s) (no-op)[/dim]")
if batch.failures:
console.print(f"[red]Failed {len(batch.failures)} file(s):[/red]")
for failure in batch.failures[:5]:
Expand Down Expand Up @@ -286,11 +301,17 @@ def resolve_create(

@main.command()
@click.argument("path", type=click.Path(exists=True, path_type=Path))
@click.option(
"--accept-changes",
is_flag=True,
default=False,
help="Acknowledge and record the current state as the new baseline after a mismatch.",
)
@click.pass_context
def verify(ctx: click.Context, path: Path) -> None:
def verify(ctx: click.Context, path: Path, accept_changes: bool) -> None:
"""Verify a folder against its previous checksum snapshot."""
store = _get_store(ctx)
report = verify_folder(path, store)
report = verify_folder(path, store, accept_changes=accept_changes)

console = Console()
if report.is_clean:
Expand All @@ -303,6 +324,10 @@ def verify(ctx: click.Context, path: Path) -> None:
console.print(f" Modified: {report.files_modified}")
if report.files_added:
console.print(f" Added: {report.files_added}")
console.print(
"\n[dim]Run with --accept-changes to acknowledge these differences "
"and set a new baseline.[/dim]"
)

# Exit with the report's exit code so scripts can switch on it.
ctx.exit(report.exit_code)
Expand Down
6 changes: 4 additions & 2 deletions src/media_mate/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
run_id INTEGER REFERENCES runs(id),
source_path TEXT NOT NULL,
destination_path TEXT NOT NULL,
operation TEXT NOT NULL, -- copy | move | link
codec_family TEXT,
resolution_bucket TEXT,
file_size INTEGER,
Expand Down Expand Up @@ -410,12 +411,13 @@ def insert_organize_op(self, record: OrganizeOpRecord) -> int:
with self._connect() as conn:
cur = conn.execute(
"INSERT INTO organize_ops (run_id, source_path, destination_path, "
"codec_family, resolution_bucket, file_size, moved_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
"operation, codec_family, resolution_bucket, file_size, moved_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
record.run_id,
record.source_path,
record.destination_path,
record.operation,
record.codec_family,
record.resolution_bucket,
record.file_size,
Expand Down
27 changes: 25 additions & 2 deletions src/media_mate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,14 @@ class MediaProbe(BaseModel):
width: int | None = None
height: int | None = None
frame_rate: float | None = None
r_frame_rate: float | None = None # real frame rate for VFR detection
is_vfr: bool = False # True when r_frame_rate differs meaningfully from frame_rate
color_space: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
bit_depth: int | None = None
sample_aspect_ratio: str | None = None # e.g. "16:9", "2:1"
timecode: str | None = None # e.g. "01:23:45:12"
audio_codec: str | None = None
audio_channels: int | None = None
audio_sample_rate: int | None = None
Expand All @@ -77,10 +81,15 @@ class OrganizeConfig(BaseModel):
"""Top-level organize configuration.

Template placeholders: {root}, {codec_family}, {resolution_bucket},
{filename}, {ext}, {date}.
{filename}, {ext}, {date}, {source_relpath}.

Default template preserves the source folder structure under dest_root
({source_relpath}), which matches how AEs and DITs think about media
(cards/scenes/takes). Use {codec_family}/{resolution_bucket} as
an alternative layout when you want codec+resolution grouping.
"""

template: str = "{root}/{codec_family}/{resolution_bucket}/{filename}{ext}"
template: str = "{root}/{source_relpath}/{filename}{ext}"
on_conflict: Literal["skip", "overwrite", "rename"] = "skip"
mode: Literal["copy", "move"] = "copy"

Expand All @@ -96,6 +105,7 @@ class OrganizeResult(BaseModel):
duration_seconds: float
dry_run: bool
errors: list[str] = Field(default_factory=list)
span_warnings: list[str] = Field(default_factory=list) # multi-file clip detections


class OrganizeOpRecord(BaseModel):
Expand All @@ -105,6 +115,7 @@ class OrganizeOpRecord(BaseModel):
run_id: int
source_path: str
destination_path: str
operation: Literal["copy", "move", "link"] # link = hardlink (same-device)
codec_family: str | None
resolution_bucket: str | None
file_size: int | None
Expand All @@ -125,6 +136,7 @@ class ProxyRequest(BaseModel):
output_path: str
codec: str = "ProRes422Proxy"
target_height: int = 1080
probe: MediaProbe | None = None # optional probe data for correct ffmpeg flags


class ProxyResult(BaseModel):
Expand All @@ -149,16 +161,26 @@ class ProxyFailure(BaseModel):
reason: str


class ProxySkip(BaseModel):
"""One file that was skipped because its proxy already existed."""

source_path: str
proxy_path: str


class ProxyBatchResult(BaseModel):
"""Output of running proxy generation on a folder.

skipped lists non-video files excluded from the batch (subtitles,
sidecar databases, ...) — they are not failures.
already_existed lists files whose proxy was already present — also not
a failure; distinct from skipped so callers can distinguish them.
"""

results: list[ProxyResult] = Field(default_factory=list)
failures: list[ProxyFailure] = Field(default_factory=list)
skipped: list[str] = Field(default_factory=list)
already_existed: list[ProxySkip] = Field(default_factory=list)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -360,6 +382,7 @@ class VerificationSnapshotRecord(BaseModel):
"ProxyRecord",
"ProxyRequest",
"ProxyResult",
"ProxySkip",
"ResolveProjectResult",
"ResolveProjectSpec",
"RunRecord",
Expand Down
103 changes: 90 additions & 13 deletions src/media_mate/organize.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@

from __future__ import annotations

import os
import re
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal

from media_mate.log import LogStore
from media_mate.models import (
Expand Down Expand Up @@ -78,6 +81,34 @@ class OrganizeError(Exception):
"pcm_f32le": "audio",
}

# Patterns that suggest a multi-file / spanned clip:
# e.g. ClipName_001.mov, ClipName_002.mxf, ClipName.RDC
_SPANNED_PATTERNS = (
r"^(?P<base>.+?)[\._](?P<seq>\d{3,6})\.[a-zA-Z0-9]+$",
r"^(?P<base>.+?)\.RDC$",
)


def _spanned_clip_groups(
files: list[Path],
) -> list[tuple[str, list[Path]]]:
"""Detect groups of multi-file / spanned clips and return (base_name, parts).

Returns a list of (base_name, [paths...]) where each group has 2+ parts.
Checks for sequential numeric suffixes (_001, _002, ...) and RED .RDC files.
"""
groups: dict[str, list[Path]] = {}
for f in files:
name = f.name
for pat in _SPANNED_PATTERNS:
m = re.match(pat, name, re.IGNORECASE)
if m:
base = m.group("base")
groups.setdefault(base, []).append(f)
break

return [(base, parts) for base, parts in groups.items() if len(parts) >= 2]


def codec_family(codec: str | None) -> str:
"""Map a codec name to a coarse family bucket.
Expand Down Expand Up @@ -121,19 +152,35 @@ def build_destination_path(
family: str,
bucket: str,
date: str | None = None,
source_root: Path | None = None,
) -> Path:
"""Render an organize template to a destination Path.

Template placeholders: {root}, {codec_family}, {resolution_bucket},
{filename}, {ext}, {date}.
{filename}, {ext}, {date}, {source_relpath}.

source_relpath is the directory part of the source file relative to
source_root (i.e., source's parent directory relative to source_root),
preserving any subfolder structure under the organize root.
"""
source_relpath = ""
if source_root is not None:
try:
# source is the file path; source_relpath is its directory
# relative to source_root (preserves card/scene subfolders)
source_relpath = str(source.parent.relative_to(source_root))
except ValueError:
# source is not under source_root
source_relpath = ""

ctx = {
"root": str(dest_root),
"codec_family": family,
"resolution_bucket": bucket,
"filename": source.stem,
"ext": source.suffix,
"date": date or datetime.now(UTC).strftime("%Y-%m-%d"),
"source_relpath": source_relpath,
}
return Path(template.format(**ctx))

Expand Down Expand Up @@ -196,6 +243,16 @@ def organize_path(

files = sorted(p for p in source.rglob("*") if p.is_file())

# Detect multi-file / spanned clips before organizing (logged as warnings)
span_warnings: list[str] = []
spanned = _spanned_clip_groups(files)
for base, parts in spanned:
span_warnings.append(
f"[SPAN] {base}: {len(parts)} files detected as multi-file clip "
f"({', '.join(p.name for p in parts)}); "
f"organizing individually — verify all parts are included"
)

if not files:
return OrganizeResult(
source_path=str(source),
Expand Down Expand Up @@ -233,7 +290,9 @@ def organize_path(

family = codec_family(probe.codec)
bucket = resolution_bucket(probe.height)
dest = build_destination_path(cfg.template, dest_root, f, family, bucket)
dest = build_destination_path(
cfg.template, dest_root, f, family, bucket, source_root=source
)

# Conflict handling
if dest.exists():
Expand All @@ -250,18 +309,34 @@ def organize_path(
if do_move:
shutil.move(str(f), str(dest))
else:
shutil.copy2(str(f), str(dest))
store.insert_organize_op(
OrganizeOpRecord(
run_id=run_id,
source_path=str(f),
destination_path=str(dest),
codec_family=family,
resolution_bucket=bucket,
file_size=size,
moved_at=datetime.now(UTC),
# Same-device: use os.link() for zero-copy.
# Both paths must be on the same filesystem (stat st_dev).
# Hardlinks share the same inode; originals remain immutable.
same_device = f.stat().st_dev == dest.parent.stat().st_dev
if same_device:
try:
os.link(str(f), str(dest))
op: Literal["copy", "move", "link"] = "link"
except OSError:
# Fallback to copy if hardlink fails (cross-fs, permissions)
shutil.copy2(str(f), str(dest))
op = "copy"
else:
shutil.copy2(str(f), str(dest))
op = "copy"

store.insert_organize_op(
OrganizeOpRecord(
run_id=run_id,
source_path=str(f),
destination_path=str(dest),
operation=op,
codec_family=family,
resolution_bucket=bucket,
file_size=size,
moved_at=datetime.now(UTC),
)
)
)

files_moved += 1
bytes_moved += size
Expand Down Expand Up @@ -296,6 +371,7 @@ def organize_path(
duration_seconds=duration,
dry_run=dry_run,
errors=errors,
span_warnings=span_warnings,
)


Expand All @@ -305,4 +381,5 @@ def organize_path(
"codec_family",
"organize_path",
"resolution_bucket",
"_spanned_clip_groups",
]
Loading
Loading