Add JSON report for WCX detection
This commit is contained in:
14
README.md
14
README.md
@ -47,6 +47,20 @@ scripts/detect_wcx_title.py \
|
|||||||
--ending mp4,avi
|
--ending mp4,avi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Write a machine-readable report without changing the terminal output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/detect_wcx_title.py \
|
||||||
|
/storage/disk1/X \
|
||||||
|
--batch \
|
||||||
|
--ending mp4 \
|
||||||
|
--json-report reports/wcx-detection.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON report contains every selected file with its classification, score,
|
||||||
|
best timestamp, or error details, followed by the same aggregate summary as
|
||||||
|
the terminal output.
|
||||||
|
|
||||||
Batch output contains one line per file with its classification, score, best
|
Batch output contains one line per file with its classification, score, best
|
||||||
timestamp, and full file path. A summary after the run reports the number
|
timestamp, and full file path. A summary after the run reports the number
|
||||||
processed and the totals for `wcx`, `uncertain`, `not_wcx`, and errors.
|
processed and the totals for `wcx`, `uncertain`, `not_wcx`, and errors.
|
||||||
|
|||||||
@ -15,10 +15,13 @@ Example:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Sequence
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
@ -90,6 +93,12 @@ def parse_arguments() -> argparse.Namespace:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--json-report",
|
||||||
|
type=Path,
|
||||||
|
help="Write a machine-readable JSON report after a batch run.",
|
||||||
|
)
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@ -484,38 +493,141 @@ def analyze_video_for_batch(
|
|||||||
def process_batch(
|
def process_batch(
|
||||||
video_files: Sequence[Path],
|
video_files: Sequence[Path],
|
||||||
analyzer: Callable[[Path], tuple[float, int, str]],
|
analyzer: Callable[[Path], tuple[float, int, str]],
|
||||||
) -> int:
|
) -> tuple[list[dict[str, object]], dict[str, int]]:
|
||||||
counts = {
|
summary = {
|
||||||
"processed": 0,
|
"processed": 0,
|
||||||
"wcx": 0,
|
"wcx": 0,
|
||||||
"uncertain": 0,
|
"uncertain": 0,
|
||||||
"not_wcx": 0,
|
"not_wcx": 0,
|
||||||
"errors": 0,
|
"errors": 0,
|
||||||
}
|
}
|
||||||
|
files = []
|
||||||
|
|
||||||
for video_file in video_files:
|
for video_file in video_files:
|
||||||
counts["processed"] += 1
|
absolute_path = str(video_file.resolve())
|
||||||
|
summary["processed"] += 1
|
||||||
try:
|
try:
|
||||||
timestamp, score, classification = analyzer(video_file)
|
timestamp, score, classification = analyzer(video_file)
|
||||||
except (OSError, RuntimeError, ValueError) as error:
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
counts["errors"] += 1
|
summary["errors"] += 1
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": absolute_path,
|
||||||
|
"classification": "error",
|
||||||
|
"score": None,
|
||||||
|
"best_timestamp": None,
|
||||||
|
"error": str(error),
|
||||||
|
}
|
||||||
|
)
|
||||||
print(f"error - - {video_file}: {error}")
|
print(f"error - - {video_file}: {error}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
counts[classification] += 1
|
summary[classification] += 1
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": absolute_path,
|
||||||
|
"classification": classification,
|
||||||
|
"score": score,
|
||||||
|
"best_timestamp": timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f"{classification:<10} {score}/8 "
|
f"{classification:<10} {score}/8 "
|
||||||
f"{timestamp:>4.1f} {video_file}"
|
f"{timestamp:>4.1f} {video_file}"
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"processed: {counts['processed']}")
|
print(f"processed: {summary['processed']}")
|
||||||
print(f"wcx: {counts['wcx']}")
|
print(f"wcx: {summary['wcx']}")
|
||||||
print(f"uncertain: {counts['uncertain']}")
|
print(f"uncertain: {summary['uncertain']}")
|
||||||
print(f"not_wcx: {counts['not_wcx']}")
|
print(f"not_wcx: {summary['not_wcx']}")
|
||||||
print(f"errors: {counts['errors']}")
|
print(f"errors: {summary['errors']}")
|
||||||
|
|
||||||
return 1 if counts["errors"] else 0
|
return files, summary
|
||||||
|
|
||||||
|
|
||||||
|
def build_json_report(
|
||||||
|
source_directory: Path,
|
||||||
|
recursive: bool,
|
||||||
|
endings: set[str],
|
||||||
|
summary: dict[str, int],
|
||||||
|
files: list[dict[str, object]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source_directory": str(source_directory.resolve()),
|
||||||
|
"recursive": recursive,
|
||||||
|
"endings": sorted(endings),
|
||||||
|
"summary": dict(summary),
|
||||||
|
"files": list(files),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_report(
|
||||||
|
report_path: Path,
|
||||||
|
report: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
if report_path.exists():
|
||||||
|
raise FileExistsError(
|
||||||
|
f"JSON report already exists: {report_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary_path = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=report_path.parent,
|
||||||
|
prefix=f".{report_path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
json.dump(
|
||||||
|
report,
|
||||||
|
temporary_file,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
temporary_file.write("\n")
|
||||||
|
temporary_file.flush()
|
||||||
|
os.fsync(temporary_file.fileno())
|
||||||
|
|
||||||
|
if report_path.exists():
|
||||||
|
raise FileExistsError(
|
||||||
|
f"JSON report already exists: {report_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
temporary_path.replace(report_path)
|
||||||
|
temporary_path = None
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_optional_json_report(
|
||||||
|
report_path: Path | None,
|
||||||
|
report: dict[str, object],
|
||||||
|
writer: Callable[
|
||||||
|
[Path, dict[str, object]],
|
||||||
|
None,
|
||||||
|
] = write_json_report,
|
||||||
|
) -> int:
|
||||||
|
if report_path is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer(report_path, report)
|
||||||
|
except (OSError, TypeError, ValueError) as error:
|
||||||
|
print(
|
||||||
|
f"Error: Could not write JSON report {report_path}: {error}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def diagnose_layout(
|
def diagnose_layout(
|
||||||
@ -663,6 +775,16 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.json_report is not None
|
||||||
|
and args.json_report.exists()
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"Error: JSON report already exists: {args.json_report}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
if not args.input_path.is_dir():
|
if not args.input_path.is_dir():
|
||||||
print(
|
print(
|
||||||
f"Error: Batch directory does not exist: {args.input_path}",
|
f"Error: Batch directory does not exist: {args.input_path}",
|
||||||
@ -686,21 +808,42 @@ def main() -> int:
|
|||||||
f"No matching video files found in: "
|
f"No matching video files found in: "
|
||||||
f"{args.input_path.resolve()}"
|
f"{args.input_path.resolve()}"
|
||||||
)
|
)
|
||||||
return process_batch((), lambda _path: (0.0, 0, "not_wcx"))
|
analyzer = lambda _path: (0.0, 0, "not_wcx")
|
||||||
|
else:
|
||||||
ffmpeg = shutil.which("ffmpeg")
|
ffmpeg = shutil.which("ffmpeg")
|
||||||
if ffmpeg is None:
|
if ffmpeg is None:
|
||||||
print("Error: ffmpeg was not found in PATH.", file=sys.stderr)
|
print("Error: ffmpeg was not found in PATH.", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return process_batch(
|
analyzer = lambda video_file: analyze_video_for_batch(
|
||||||
video_files,
|
|
||||||
lambda video_file: analyze_video_for_batch(
|
|
||||||
ffmpeg,
|
ffmpeg,
|
||||||
video_file,
|
video_file,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
files, summary = process_batch(
|
||||||
|
video_files,
|
||||||
|
analyzer,
|
||||||
|
)
|
||||||
|
report = build_json_report(
|
||||||
|
source_directory=args.input_path,
|
||||||
|
recursive=args.recursive,
|
||||||
|
endings=endings,
|
||||||
|
summary=summary,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
report_exit_code = write_optional_json_report(
|
||||||
|
args.json_report,
|
||||||
|
report,
|
||||||
|
)
|
||||||
|
return 1 if summary["errors"] or report_exit_code else 0
|
||||||
|
|
||||||
|
if args.json_report is not None:
|
||||||
|
print(
|
||||||
|
"Error: --json-report requires --batch.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
if args.recursive:
|
if args.recursive:
|
||||||
print(
|
print(
|
||||||
"Error: --recursive requires --batch.",
|
"Error: --recursive requires --batch.",
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_PATH = (
|
SCRIPT_PATH = (
|
||||||
@ -413,12 +416,13 @@ class BatchModeTests(unittest.TestCase):
|
|||||||
|
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
with contextlib.redirect_stdout(output):
|
with contextlib.redirect_stdout(output):
|
||||||
exit_code = detect_wcx_title.process_batch(
|
files, summary = detect_wcx_title.process_batch(
|
||||||
video_files,
|
video_files,
|
||||||
lambda path: results[path],
|
lambda path: results[path],
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(exit_code, 0)
|
self.assertEqual(len(files), 3)
|
||||||
|
self.assertEqual(summary["errors"], 0)
|
||||||
self.assertIn("processed: 3", output.getvalue())
|
self.assertIn("processed: 3", output.getvalue())
|
||||||
self.assertIn("wcx: 1", output.getvalue())
|
self.assertIn("wcx: 1", output.getvalue())
|
||||||
self.assertIn("uncertain: 1", output.getvalue())
|
self.assertIn("uncertain: 1", output.getvalue())
|
||||||
@ -438,16 +442,228 @@ class BatchModeTests(unittest.TestCase):
|
|||||||
|
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
with contextlib.redirect_stdout(output):
|
with contextlib.redirect_stdout(output):
|
||||||
exit_code = detect_wcx_title.process_batch(
|
files, summary = detect_wcx_title.process_batch(
|
||||||
video_files,
|
video_files,
|
||||||
analyze,
|
analyze,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(exit_code, 1)
|
self.assertEqual(summary["errors"], 1)
|
||||||
|
self.assertEqual(files[1]["classification"], "error")
|
||||||
self.assertIn("error", output.getvalue())
|
self.assertIn("error", output.getvalue())
|
||||||
self.assertIn("processed: 2", output.getvalue())
|
self.assertIn("processed: 2", output.getvalue())
|
||||||
self.assertIn("errors: 1", output.getvalue())
|
self.assertIn("errors: 1", output.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class JsonReportTests(unittest.TestCase):
|
||||||
|
def test_report_contains_results_summary_and_sorted_endings(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
source_directory = Path(temporary_directory)
|
||||||
|
video_files = (
|
||||||
|
source_directory / "a.mp4",
|
||||||
|
source_directory / "b.avi",
|
||||||
|
source_directory / "c.mp4",
|
||||||
|
)
|
||||||
|
results = {
|
||||||
|
video_files[0]: (8.0, 8, "wcx"),
|
||||||
|
video_files[1]: (10.0, 4, "not_wcx"),
|
||||||
|
video_files[2]: (12.0, 7, "uncertain"),
|
||||||
|
}
|
||||||
|
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
files, summary = detect_wcx_title.process_batch(
|
||||||
|
video_files,
|
||||||
|
lambda path: results[path],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = detect_wcx_title.build_json_report(
|
||||||
|
source_directory=source_directory,
|
||||||
|
recursive=False,
|
||||||
|
endings={"mp4", "avi"},
|
||||||
|
summary=summary,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
report_path = (
|
||||||
|
source_directory
|
||||||
|
/ "new-parent"
|
||||||
|
/ "report.json"
|
||||||
|
)
|
||||||
|
detect_wcx_title.write_json_report(
|
||||||
|
report_path,
|
||||||
|
report,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(report_path.parent.is_dir())
|
||||||
|
self.assertTrue(
|
||||||
|
report_path.read_bytes().endswith(b"\n")
|
||||||
|
)
|
||||||
|
with report_path.open(encoding="utf-8") as report_file:
|
||||||
|
loaded = json.load(report_file)
|
||||||
|
|
||||||
|
self.assertEqual(loaded["endings"], ["avi", "mp4"])
|
||||||
|
self.assertEqual(
|
||||||
|
loaded["source_directory"],
|
||||||
|
str(source_directory.resolve()),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[item["classification"] for item in loaded["files"]],
|
||||||
|
["wcx", "not_wcx", "uncertain"],
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
Path(item["path"]).is_absolute()
|
||||||
|
for item in loaded["files"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
loaded["summary"],
|
||||||
|
{
|
||||||
|
"processed": 3,
|
||||||
|
"wcx": 1,
|
||||||
|
"uncertain": 1,
|
||||||
|
"not_wcx": 1,
|
||||||
|
"errors": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_report_contains_error_result(self) -> None:
|
||||||
|
video_file = Path("/videos/broken.mp4")
|
||||||
|
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
files, summary = detect_wcx_title.process_batch(
|
||||||
|
(video_file,),
|
||||||
|
lambda _path: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("mock ffmpeg failure")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = detect_wcx_title.build_json_report(
|
||||||
|
source_directory=Path("/videos"),
|
||||||
|
recursive=False,
|
||||||
|
endings={"mp4"},
|
||||||
|
summary=summary,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
report["files"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"path": str(video_file.resolve()),
|
||||||
|
"classification": "error",
|
||||||
|
"score": None,
|
||||||
|
"best_timestamp": None,
|
||||||
|
"error": "mock ffmpeg failure",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(report["summary"]["errors"], 1)
|
||||||
|
|
||||||
|
def test_empty_batch_report(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
with contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
files, summary = detect_wcx_title.process_batch(
|
||||||
|
(),
|
||||||
|
lambda _path: (8.0, 8, "wcx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = detect_wcx_title.build_json_report(
|
||||||
|
source_directory=Path(temporary_directory),
|
||||||
|
recursive=True,
|
||||||
|
endings={"mp4"},
|
||||||
|
summary=summary,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
report_path = (
|
||||||
|
Path(temporary_directory)
|
||||||
|
/ "reports"
|
||||||
|
/ "empty.json"
|
||||||
|
)
|
||||||
|
detect_wcx_title.write_json_report(report_path, report)
|
||||||
|
|
||||||
|
with report_path.open(encoding="utf-8") as report_file:
|
||||||
|
loaded = json.load(report_file)
|
||||||
|
|
||||||
|
self.assertEqual(loaded["files"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
loaded["summary"],
|
||||||
|
{
|
||||||
|
"processed": 0,
|
||||||
|
"wcx": 0,
|
||||||
|
"uncertain": 0,
|
||||||
|
"not_wcx": 0,
|
||||||
|
"errors": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_report_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
report_path = Path(temporary_directory) / "report.json"
|
||||||
|
report_path.write_text("existing", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(FileExistsError):
|
||||||
|
detect_wcx_title.write_json_report(
|
||||||
|
report_path,
|
||||||
|
{"schema_version": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
report_path.read_text(encoding="utf-8"),
|
||||||
|
"existing",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_report_write_failure_returns_one(self) -> None:
|
||||||
|
def failing_writer(
|
||||||
|
_path: Path,
|
||||||
|
_report: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
raise OSError("mock write failure")
|
||||||
|
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
exit_code = detect_wcx_title.write_optional_json_report(
|
||||||
|
Path("/reports/report.json"),
|
||||||
|
{"schema_version": 1},
|
||||||
|
writer=failing_writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 1)
|
||||||
|
|
||||||
|
def test_json_report_without_batch_is_rejected(self) -> None:
|
||||||
|
arguments = [
|
||||||
|
"detect_wcx_title.py",
|
||||||
|
"/videos/movie.mp4",
|
||||||
|
"--json-report",
|
||||||
|
"/reports/report.json",
|
||||||
|
]
|
||||||
|
with patch.object(sys, "argv", arguments):
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
exit_code = detect_wcx_title.main()
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 2)
|
||||||
|
|
||||||
|
def test_existing_report_stops_before_batch_processing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
directory = Path(temporary_directory)
|
||||||
|
report_path = directory / "report.json"
|
||||||
|
report_path.write_text("existing", encoding="utf-8")
|
||||||
|
arguments = [
|
||||||
|
"detect_wcx_title.py",
|
||||||
|
str(directory),
|
||||||
|
"--batch",
|
||||||
|
"--json-report",
|
||||||
|
str(report_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(sys, "argv", arguments):
|
||||||
|
with patch.object(
|
||||||
|
detect_wcx_title,
|
||||||
|
"process_batch",
|
||||||
|
) as process_batch:
|
||||||
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
|
exit_code = detect_wcx_title.main()
|
||||||
|
|
||||||
|
self.assertEqual(exit_code, 2)
|
||||||
|
process_batch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user