Improve progress reporting and video format coverage

This commit is contained in:
kim
2026-03-30 15:24:46 +08:00
parent 6125a9389c
commit 7e74de3e08
8 changed files with 244 additions and 17 deletions
+33 -3
View File
@@ -8,10 +8,15 @@ import orjson
import typer
from tgvideo_check.classifiers import classify_stage1_result
from tgvideo_check.config import ScanConfig, normalize_extension
from tgvideo_check.config import (
DEFAULT_VIDEO_EXTENSIONS,
ScanConfig,
normalize_extension,
)
from tgvideo_check.discovery import discover_files
from tgvideo_check.ffmpeg_runner import generate_sample_offsets, run_ffmpeg_verification
from tgvideo_check.ffprobe_runner import run_ffprobe
from tgvideo_check.models import StoredFile
from tgvideo_check.profiles import resolve_config
from tgvideo_check.reporting import ReportRow, export_csv_summary, export_jsonl
from tgvideo_check.scheduler import (
@@ -29,6 +34,9 @@ app = typer.Typer(
)
DISCOVERY_PROGRESS_EVERY = 100
PROGRESS_HEARTBEAT_SECONDS = 15.0
PROGRESS_COUNT_MAX_INTERVAL = 25
DEFAULT_VIDEO_EXTENSIONS_CSV = ",".join(DEFAULT_VIDEO_EXTENSIONS)
@dataclass
@@ -54,9 +62,19 @@ class _StageProgress:
def _should_report(self, completed: int) -> bool:
if completed == self.total or completed == 1:
return True
report_interval = max(1, self.total // 20)
report_interval = min(PROGRESS_COUNT_MAX_INTERVAL, max(1, self.total // 20))
return completed - self.last_reported >= report_interval
def heartbeat(self, unfinished: tuple[StoredFile, ...]) -> None:
completed = sum(self.counts.values())
percent = (completed * 100) // self.total if self.total else 100
remaining = self.total - completed
summary_text = _format_progress_counts(self.label, self.counts) or "no_result_yet"
typer.echo(
f"{self.label} heartbeat: {completed}/{self.total} ({percent}%) "
f"remaining={remaining} batch_unfinished={len(unfinished)} {summary_text}".rstrip()
)
class ScanProgressReporter:
def discovery_started(self, roots: tuple[Path, ...]) -> None:
@@ -128,7 +146,7 @@ def scan(
profile: str = typer.Option("tg-hdd", "--profile"),
recursive: bool = typer.Option(True, "--recursive/--no-recursive"),
extensions: str = typer.Option(
"mp4,mov,m4v,mkv,webm,avi",
DEFAULT_VIDEO_EXTENSIONS_CSV,
"--extensions",
help="Comma-separated video extensions.",
),
@@ -289,6 +307,12 @@ def _execute_scan(config: ScanConfig) -> list[ReportRow]:
for stored_file, probe_result in iter_stage_batch_results(
batch,
lambda item: run_ffprobe(item.path),
heartbeat_seconds=PROGRESS_HEARTBEAT_SECONDS,
on_heartbeat=(
(lambda unfinished: stage1_progress.heartbeat(unfinished))
if stage1_progress is not None
else None
),
):
store.record_stage1_result(
stored_file.file_id,
@@ -374,6 +398,12 @@ def _execute_scan(config: ScanConfig) -> list[ReportRow]:
sample_duration=config.sample_duration or 3,
full_decode_confirm=config.full_decode_confirm,
),
heartbeat_seconds=PROGRESS_HEARTBEAT_SECONDS,
on_heartbeat=(
(lambda unfinished: stage2_progress.heartbeat(unfinished))
if stage2_progress is not None
else None
),
):
store.record_stage2_result(
stored_file.file_id,
+33 -1
View File
@@ -4,6 +4,38 @@ from dataclasses import dataclass, replace
from pathlib import Path
DEFAULT_VIDEO_EXTENSIONS: tuple[str, ...] = (
"mp4",
"mov",
"m4v",
"mkv",
"webm",
"avi",
"flv",
"f4v",
"ts",
"m2ts",
"mts",
"mpg",
"mpeg",
"mpe",
"mpv",
"m2v",
"wmv",
"asf",
"3gp",
"3g2",
"ogv",
"ogm",
"vob",
"qt",
"rm",
"rmvb",
"mxf",
"divx",
)
def normalize_extension(value: str) -> str:
return value.lower().lstrip(".")
@@ -13,7 +45,7 @@ class ScanConfig:
roots: tuple[Path, ...]
profile: str = "tg-hdd"
recursive: bool = True
extensions: tuple[str, ...] = ("mp4", "mov", "m4v", "mkv", "webm", "avi")
extensions: tuple[str, ...] = DEFAULT_VIDEO_EXTENSIONS
db_path: Path = Path("scan_state.db")
resume: bool = True
stage1_workers: int | None = None
+7 -4
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
from dataclasses import dataclass, replace
from tgvideo_check.config import ScanConfig
from tgvideo_check.config import DEFAULT_VIDEO_EXTENSIONS, ScanConfig
TG_HIGH_RISK_EXTENSIONS: tuple[str, ...] = ("mp4", "mov", "m4v", "mkv", "webm")
@dataclass(frozen=True)
@@ -20,21 +23,21 @@ PROFILES: dict[str, Profile] = {
stage1_workers=1,
stage2_workers=1,
sample_duration=3,
high_risk_extensions=("mp4", "mov", "m4v", "mkv", "webm"),
high_risk_extensions=TG_HIGH_RISK_EXTENSIONS,
),
"tg-fast": Profile(
name="tg-fast",
stage1_workers=2,
stage2_workers=1,
sample_duration=3,
high_risk_extensions=("mp4", "mov", "m4v", "mkv", "webm"),
high_risk_extensions=TG_HIGH_RISK_EXTENSIONS,
),
"deep-verify": Profile(
name="deep-verify",
stage1_workers=1,
stage2_workers=1,
sample_duration=5,
high_risk_extensions=("mp4", "mov", "m4v", "mkv", "webm", "avi"),
high_risk_extensions=DEFAULT_VIDEO_EXTENSIONS,
),
}
+36 -5
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
from typing import TypeVar
from tgvideo_check.models import StoredFile
@@ -84,20 +84,51 @@ def execute_stage_batch(
def iter_stage_batch_results(
batch: StageBatch,
worker: Callable[[StoredFile], T],
*,
heartbeat_seconds: float | None = None,
on_heartbeat: Callable[[tuple[StoredFile, ...]], None] | None = None,
) -> Iterator[tuple[StoredFile, T]]:
if len(batch.files) <= 1 or batch.worker_count <= 1:
use_heartbeat = (
on_heartbeat is not None
and heartbeat_seconds is not None
and heartbeat_seconds > 0
)
if not use_heartbeat and (len(batch.files) <= 1 or batch.worker_count <= 1):
for item in batch.files:
yield item, worker(item)
return
max_workers = min(batch.worker_count, len(batch.files))
max_workers = min(max(1, batch.worker_count), len(batch.files))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(worker, item): item
for item in batch.files
}
for future in as_completed(futures):
yield futures[future], future.result()
if not use_heartbeat:
for future in as_completed(futures):
yield futures[future], future.result()
return
pending = set(futures)
while pending:
done, pending = wait(
pending,
timeout=heartbeat_seconds,
return_when=FIRST_COMPLETED,
)
if not done:
pending_items = tuple(
sorted(
(futures[future] for future in pending),
key=lambda item: item.discovery_order,
)
)
on_heartbeat(pending_items)
continue
for future in done:
yield futures[future], future.result()
def _chunk_by_limit(