Improve progress reporting and video format coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user