Fix resume DB growth and add compaction command
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
- 递归扫描一个或多个目录中的常见视频扩展名,默认覆盖 `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`
|
- 递归扫描一个或多个目录中的常见视频扩展名,默认覆盖 `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`
|
||||||
- 支持从 `txt` 文件批量导入目录列表
|
- 支持从 `txt` 文件批量导入目录列表
|
||||||
- 使用 SQLite 落盘扫描状态,并基于 `path + size + mtime` 复用旧结果
|
- 使用 SQLite 落盘扫描状态,并基于 `path + size + mtime` 复用旧结果
|
||||||
|
- 当同一组 `roots + profile` 的扫描上次未完成时,`scan --resume` 会直接续跑同一个 run,不再把 discovery 结果整轮复制一遍
|
||||||
- stage1 使用 `ffprobe` 做容器级快速校验
|
- stage1 使用 `ffprobe` 做容器级快速校验
|
||||||
- `scan` 默认只执行 stage1,把需要详细复核的文件标记为 `pending_stage2`
|
- `scan` 默认只执行 stage1,把需要详细复核的文件标记为 `pending_stage2`
|
||||||
- `verify-stage2` 只对 `pending_stage2` 文件使用 `ffmpeg` 做定向采样或完整确认
|
- `verify-stage2` 只对 `pending_stage2` 文件使用 `ffmpeg` 做定向采样或完整确认
|
||||||
@@ -143,6 +144,7 @@ tgvideo-check scan "D:\extra-folder" --paths-file .\dirs.txt --db .\scan_state.d
|
|||||||
- `--queue-limit`:限制单阶段批次大小,避免把待处理文件一次性堆进内存
|
- `--queue-limit`:限制单阶段批次大小,避免把待处理文件一次性堆进内存
|
||||||
- `verify-stage2 --full-decode-confirm`:对第二轮强制使用完整解码确认
|
- `verify-stage2 --full-decode-confirm`:对第二轮强制使用完整解码确认
|
||||||
- `scan --resume / --no-resume`:控制是否复用旧的第一轮结果
|
- `scan --resume / --no-resume`:控制是否复用旧的第一轮结果
|
||||||
|
- `compact-db`:清理同一组 `roots + profile` 下的重复历史 run,并可选执行 `VACUUM`
|
||||||
|
|
||||||
命令边界:
|
命令边界:
|
||||||
|
|
||||||
@@ -234,6 +236,34 @@ tgvideo-check inspect-stage1 --db "C:\Users\Administrator\Documents\clean-tgvide
|
|||||||
- `stdout_head=`:标准输出开头预览,便于快速看出是不是残缺 JSON
|
- `stdout_head=`:标准输出开头预览,便于快速看出是不是残缺 JSON
|
||||||
- `stderr_head=`:标准错误预览,常能直接看到 `moov atom not found`、`error reading header` 等真实媒体错误
|
- `stderr_head=`:标准错误预览,常能直接看到 `moov atom not found`、`error reading header` 等真实媒体错误
|
||||||
|
|
||||||
|
### 压缩重复扫描数据库
|
||||||
|
|
||||||
|
如果你之前在同一批目录上反复 `scan`,导致同一组 `roots + profile` 下面堆积了多份历史 run,可以保留一个目标 run,把其余重复 run 删掉:
|
||||||
|
|
||||||
|
先预览:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
tgvideo-check compact-db --db .\scan_state.db
|
||||||
|
```
|
||||||
|
|
||||||
|
真实执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
tgvideo-check compact-db --db .\scan_state.db --no-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要明确保留某个 run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
tgvideo-check compact-db --db .\scan_state.db --run-id 12 --no-dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 默认保留最新 run;如果你在做中断恢复,通常就保留当前正在续跑的那个 run
|
||||||
|
- 这个命令只清理同一组 `roots + profile` 下的重复历史 run,不会顺手删除别的扫描任务
|
||||||
|
- 默认会在真实删除后执行 `VACUUM`,把 SQLite 文件物理收缩下来
|
||||||
|
|
||||||
### 原因码说明
|
### 原因码说明
|
||||||
|
|
||||||
导出的 `reason_code` / `reason_code_zh` 含义如下:
|
导出的 `reason_code` / `reason_code_zh` 含义如下:
|
||||||
|
|||||||
@@ -359,6 +359,54 @@ def prune(
|
|||||||
typer.echo(f"Reasons: {reason_text}")
|
typer.echo(f"Reasons: {reason_text}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("compact-db")
|
||||||
|
def compact_db(
|
||||||
|
db_path: Path = typer.Option(Path("scan_state.db"), "--db"),
|
||||||
|
run_id: int | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--run-id",
|
||||||
|
help="Keep this run and remove duplicate runs with the same roots/profile.",
|
||||||
|
),
|
||||||
|
dry_run: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--dry-run/--no-dry-run",
|
||||||
|
help="Preview duplicate run cleanup without deleting rows.",
|
||||||
|
),
|
||||||
|
vacuum: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--vacuum/--no-vacuum",
|
||||||
|
help="Run SQLite VACUUM after deleting duplicate runs.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Compact duplicate scan runs for the same roots/profile lineage."""
|
||||||
|
|
||||||
|
store = ScanStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
selected_run_id = run_id or store.get_latest_run_id(prefer_completed=False)
|
||||||
|
if selected_run_id is None:
|
||||||
|
raise typer.BadParameter(f"No scan runs found in database: {db_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = store.compact_duplicate_runs(
|
||||||
|
selected_run_id,
|
||||||
|
dry_run=dry_run,
|
||||||
|
vacuum=vacuum,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise typer.BadParameter(str(exc)) from exc
|
||||||
|
|
||||||
|
typer.echo(
|
||||||
|
"Compact-db: "
|
||||||
|
f"keep_run_id={summary.keep_run_id} "
|
||||||
|
f"deleted_runs={summary.deleted_run_count} "
|
||||||
|
f"deleted_files={summary.deleted_file_count} "
|
||||||
|
f"deleted_stage1_results={summary.deleted_stage1_count} "
|
||||||
|
f"deleted_stage2_results={summary.deleted_stage2_count} "
|
||||||
|
f"dry_run={'yes' if summary.dry_run else 'no'} "
|
||||||
|
f"vacuumed={'yes' if summary.vacuumed else 'no'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
app()
|
app()
|
||||||
|
|
||||||
@@ -366,11 +414,23 @@ def main() -> None:
|
|||||||
def _execute_stage1_scan(config: ScanConfig) -> list[ReportRow]:
|
def _execute_stage1_scan(config: ScanConfig) -> list[ReportRow]:
|
||||||
store = ScanStore(config.db_path)
|
store = ScanStore(config.db_path)
|
||||||
store.initialize()
|
store.initialize()
|
||||||
|
resumed_existing_run = False
|
||||||
|
run_id = (
|
||||||
|
store.find_resumable_run_id(profile=config.profile, root_paths=config.roots)
|
||||||
|
if config.resume
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if run_id is None:
|
||||||
run_id = store.create_run(profile=config.profile, root_paths=config.roots)
|
run_id = store.create_run(profile=config.profile, root_paths=config.roots)
|
||||||
|
else:
|
||||||
|
resumed_existing_run = True
|
||||||
|
store.update_run_status(run_id, status="running")
|
||||||
reasons: dict[int, str] = {}
|
reasons: dict[int, str] = {}
|
||||||
progress = ScanProgressReporter()
|
progress = ScanProgressReporter()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if resumed_existing_run:
|
||||||
|
typer.echo(f"Resume: reusing unfinished run_id={run_id}")
|
||||||
progress.discovery_started(config.roots)
|
progress.discovery_started(config.roots)
|
||||||
candidates = discover_files(
|
candidates = discover_files(
|
||||||
config.roots,
|
config.roots,
|
||||||
|
|||||||
+246
-2
@@ -17,6 +17,21 @@ class ScanRun:
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunCompactionSummary:
|
||||||
|
keep_run_id: int
|
||||||
|
duplicate_run_ids: tuple[int, ...]
|
||||||
|
deleted_file_count: int
|
||||||
|
deleted_stage1_count: int
|
||||||
|
deleted_stage2_count: int
|
||||||
|
dry_run: bool
|
||||||
|
vacuumed: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deleted_run_count(self) -> int:
|
||||||
|
return len(self.duplicate_run_ids)
|
||||||
|
|
||||||
|
|
||||||
class ScanStore:
|
class ScanStore:
|
||||||
def __init__(self, db_path: Path) -> None:
|
def __init__(self, db_path: Path) -> None:
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
@@ -79,7 +94,7 @@ class ScanStore:
|
|||||||
self._ensure_column(connection, "files", "reason_code", "TEXT")
|
self._ensure_column(connection, "files", "reason_code", "TEXT")
|
||||||
|
|
||||||
def create_run(self, *, profile: str, root_paths: tuple[Path, ...]) -> int:
|
def create_run(self, *, profile: str, root_paths: tuple[Path, ...]) -> int:
|
||||||
root_paths_json = orjson.dumps([str(path) for path in root_paths]).decode("utf-8")
|
root_paths_json = self._serialize_root_paths(root_paths)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
@@ -90,6 +105,29 @@ class ScanStore:
|
|||||||
)
|
)
|
||||||
return int(cursor.lastrowid)
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def find_resumable_run_id(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
profile: str,
|
||||||
|
root_paths: tuple[Path, ...],
|
||||||
|
) -> int | None:
|
||||||
|
root_paths_json = self._serialize_root_paths(root_paths)
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM scan_runs
|
||||||
|
WHERE profile = ? AND root_paths_json = ? AND status <> 'completed'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile, root_paths_json),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return int(row["id"])
|
||||||
|
|
||||||
def get_latest_run_id(self, *, prefer_completed: bool = True) -> int | None:
|
def get_latest_run_id(self, *, prefer_completed: bool = True) -> int | None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
if prefer_completed:
|
if prefer_completed:
|
||||||
@@ -128,6 +166,20 @@ class ScanStore:
|
|||||||
) -> list[StoredFile]:
|
) -> list[StoredFile]:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
|
current = self._find_current_file(connection, run_id, candidate.path)
|
||||||
|
current_matches = (
|
||||||
|
current is not None
|
||||||
|
and self._row_matches_candidate(current, candidate)
|
||||||
|
)
|
||||||
|
previous = None
|
||||||
|
|
||||||
|
if current_matches:
|
||||||
|
stage1_status = current["stage1_status"]
|
||||||
|
stage2_status = current["stage2_status"]
|
||||||
|
final_status = current["final_status"]
|
||||||
|
resumed_from_run_id = current["resumed_from_run_id"]
|
||||||
|
reason_code = current["reason_code"]
|
||||||
|
else:
|
||||||
previous = (
|
previous = (
|
||||||
self._find_previous_match(
|
self._find_previous_match(
|
||||||
connection,
|
connection,
|
||||||
@@ -185,7 +237,11 @@ class ScanStore:
|
|||||||
reason_code,
|
reason_code,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
current_file_id = connection.execute(
|
current_file_id = (
|
||||||
|
int(current["file_id"])
|
||||||
|
if current is not None
|
||||||
|
else int(
|
||||||
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM files
|
FROM files
|
||||||
@@ -193,6 +249,12 @@ class ScanStore:
|
|||||||
""",
|
""",
|
||||||
(run_id, str(candidate.path)),
|
(run_id, str(candidate.path)),
|
||||||
).fetchone()["id"]
|
).fetchone()["id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if current is not None and not current_matches:
|
||||||
|
self._clear_file_results(connection, current_file_id)
|
||||||
|
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
self._copy_previous_results(
|
self._copy_previous_results(
|
||||||
connection,
|
connection,
|
||||||
@@ -451,6 +513,128 @@ class ScanStore:
|
|||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def compact_duplicate_runs(
|
||||||
|
self,
|
||||||
|
keep_run_id: int,
|
||||||
|
*,
|
||||||
|
dry_run: bool = True,
|
||||||
|
vacuum: bool = True,
|
||||||
|
) -> RunCompactionSummary:
|
||||||
|
keep_run = self.get_run(keep_run_id)
|
||||||
|
if keep_run is None:
|
||||||
|
raise ValueError(f"Scan run does not exist: {keep_run_id}")
|
||||||
|
|
||||||
|
duplicate_run_ids: tuple[int, ...]
|
||||||
|
deleted_file_count = 0
|
||||||
|
deleted_stage1_count = 0
|
||||||
|
deleted_stage2_count = 0
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
duplicate_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM scan_runs
|
||||||
|
WHERE profile = ? AND root_paths_json = ? AND id <> ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(keep_run.profile, keep_run.root_paths_json, keep_run_id),
|
||||||
|
).fetchall()
|
||||||
|
duplicate_run_ids = tuple(int(row["id"]) for row in duplicate_rows)
|
||||||
|
|
||||||
|
if duplicate_run_ids:
|
||||||
|
placeholders = ",".join("?" for _ in duplicate_run_ids)
|
||||||
|
deleted_file_count = int(
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
deleted_stage1_count = int(
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM stage1_results
|
||||||
|
WHERE file_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
deleted_stage2_count = int(
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM stage2_results
|
||||||
|
WHERE file_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM stage1_results
|
||||||
|
WHERE file_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM stage2_results
|
||||||
|
WHERE file_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM files
|
||||||
|
WHERE run_id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM scan_runs
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
duplicate_run_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
vacuumed = bool(duplicate_run_ids) and not dry_run and vacuum
|
||||||
|
if vacuumed:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("VACUUM")
|
||||||
|
|
||||||
|
return RunCompactionSummary(
|
||||||
|
keep_run_id=keep_run_id,
|
||||||
|
duplicate_run_ids=duplicate_run_ids,
|
||||||
|
deleted_file_count=deleted_file_count,
|
||||||
|
deleted_stage1_count=deleted_stage1_count,
|
||||||
|
deleted_stage2_count=deleted_stage2_count,
|
||||||
|
dry_run=dry_run,
|
||||||
|
vacuumed=vacuumed,
|
||||||
|
)
|
||||||
|
|
||||||
def _rows_to_stored_files(self, rows: list[sqlite3.Row]) -> list[StoredFile]:
|
def _rows_to_stored_files(self, rows: list[sqlite3.Row]) -> list[StoredFile]:
|
||||||
return [
|
return [
|
||||||
StoredFile(
|
StoredFile(
|
||||||
@@ -475,6 +659,46 @@ class ScanStore:
|
|||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
return connection
|
return connection
|
||||||
|
|
||||||
|
def _serialize_root_paths(self, root_paths: tuple[Path, ...]) -> str:
|
||||||
|
return orjson.dumps([str(path) for path in root_paths]).decode("utf-8")
|
||||||
|
|
||||||
|
def _find_current_file(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
run_id: int,
|
||||||
|
path: Path,
|
||||||
|
) -> sqlite3.Row | None:
|
||||||
|
return connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id AS file_id,
|
||||||
|
run_id,
|
||||||
|
path,
|
||||||
|
size,
|
||||||
|
mtime_ns,
|
||||||
|
extension,
|
||||||
|
stage1_status,
|
||||||
|
stage2_status,
|
||||||
|
final_status,
|
||||||
|
resumed_from_run_id,
|
||||||
|
reason_code
|
||||||
|
FROM files
|
||||||
|
WHERE run_id = ? AND path = ?
|
||||||
|
""",
|
||||||
|
(run_id, str(path)),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
def _row_matches_candidate(
|
||||||
|
self,
|
||||||
|
row: sqlite3.Row,
|
||||||
|
candidate: FileCandidate,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
row["size"] == candidate.size
|
||||||
|
and row["mtime_ns"] == candidate.mtime_ns
|
||||||
|
and row["extension"] == candidate.extension
|
||||||
|
)
|
||||||
|
|
||||||
def _find_previous_match(
|
def _find_previous_match(
|
||||||
self,
|
self,
|
||||||
connection: sqlite3.Connection,
|
connection: sqlite3.Connection,
|
||||||
@@ -502,6 +726,26 @@ class ScanStore:
|
|||||||
"""
|
"""
|
||||||
return connection.execute(query, params).fetchone()
|
return connection.execute(query, params).fetchone()
|
||||||
|
|
||||||
|
def _clear_file_results(
|
||||||
|
self,
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
file_id: int,
|
||||||
|
) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM stage1_results
|
||||||
|
WHERE file_id = ?
|
||||||
|
""",
|
||||||
|
(file_id,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM stage2_results
|
||||||
|
WHERE file_id = ?
|
||||||
|
""",
|
||||||
|
(file_id,),
|
||||||
|
)
|
||||||
|
|
||||||
def _copy_previous_results(
|
def _copy_previous_results(
|
||||||
self,
|
self,
|
||||||
connection: sqlite3.Connection,
|
connection: sqlite3.Connection,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -490,6 +491,66 @@ def test_scan_resume_preserves_reason_code_in_exported_report(tmp_path, monkeypa
|
|||||||
assert '"reason_code":"ffprobe_exit_nonzero"' in second_jsonl.read_text(encoding="utf-8")
|
assert '"reason_code":"ffprobe_exit_nonzero"' in second_jsonl.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_resume_reuses_incomplete_run_without_duplication(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
root = tmp_path / "videos"
|
||||||
|
root.mkdir()
|
||||||
|
video = root / "video.avi"
|
||||||
|
video.write_bytes(b"video")
|
||||||
|
db_path = tmp_path / "scan_state.db"
|
||||||
|
|
||||||
|
store = ScanStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
run_id = store.create_run(profile="tg-hdd", root_paths=(root,))
|
||||||
|
candidate = FileCandidate(
|
||||||
|
path=video,
|
||||||
|
size=video.stat().st_size,
|
||||||
|
mtime_ns=video.stat().st_mtime_ns,
|
||||||
|
extension="avi",
|
||||||
|
discovery_order=0,
|
||||||
|
)
|
||||||
|
[stored] = store.upsert_files(run_id, [candidate])
|
||||||
|
store.record_stage1_result(
|
||||||
|
stored.file_id,
|
||||||
|
exit_code=0,
|
||||||
|
stdout_text='{"format":{"format_name":"avi","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||||
|
stderr_text="",
|
||||||
|
elapsed_ms=1,
|
||||||
|
)
|
||||||
|
store.record_stage1_status(
|
||||||
|
stored.file_id,
|
||||||
|
stage1_status="passed_stage1",
|
||||||
|
final_status="passed_stage1",
|
||||||
|
reason_code="passed_stage1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_ffprobe(*args, **kwargs):
|
||||||
|
raise AssertionError("resume within same run should not call ffprobe")
|
||||||
|
|
||||||
|
monkeypatch.setattr("tgvideo_check.cli.run_ffprobe", fail_ffprobe)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"scan",
|
||||||
|
str(root),
|
||||||
|
"--db",
|
||||||
|
str(db_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "passed_stage1=1" in result.stdout
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as connection:
|
||||||
|
run_count = connection.execute("SELECT COUNT(*) FROM scan_runs").fetchone()[0]
|
||||||
|
file_count = connection.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
||||||
|
stage1_count = connection.execute("SELECT COUNT(*) FROM stage1_results").fetchone()[0]
|
||||||
|
|
||||||
|
assert run_count == 1
|
||||||
|
assert file_count == 1
|
||||||
|
assert stage1_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_scan_resume_does_not_reuse_previous_stage2_results(tmp_path, monkeypatch) -> None:
|
def test_scan_resume_does_not_reuse_previous_stage2_results(tmp_path, monkeypatch) -> None:
|
||||||
root = tmp_path / "videos"
|
root = tmp_path / "videos"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
@@ -581,6 +642,76 @@ def test_scan_resume_does_not_reuse_previous_stage2_results(tmp_path, monkeypatc
|
|||||||
assert probe_calls == 1
|
assert probe_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_db_command_keeps_selected_run_and_removes_duplicate_runs(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "videos"
|
||||||
|
root.mkdir()
|
||||||
|
video = root / "video.mp4"
|
||||||
|
video.write_bytes(b"video")
|
||||||
|
db_path = tmp_path / "scan_state.db"
|
||||||
|
|
||||||
|
store = ScanStore(db_path)
|
||||||
|
store.initialize()
|
||||||
|
candidate = FileCandidate(
|
||||||
|
path=video,
|
||||||
|
size=video.stat().st_size,
|
||||||
|
mtime_ns=video.stat().st_mtime_ns,
|
||||||
|
extension="mp4",
|
||||||
|
discovery_order=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_run = store.create_run(profile="tg-hdd", root_paths=(root,))
|
||||||
|
[first_stored] = store.upsert_files(first_run, [candidate])
|
||||||
|
store.record_stage1_result(
|
||||||
|
first_stored.file_id,
|
||||||
|
exit_code=0,
|
||||||
|
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"},"streams":[{"codec_type":"video"}]}',
|
||||||
|
stderr_text="",
|
||||||
|
elapsed_ms=1,
|
||||||
|
)
|
||||||
|
store.record_stage1_status(
|
||||||
|
first_stored.file_id,
|
||||||
|
stage1_status="passed_stage1",
|
||||||
|
final_status="passed_stage1",
|
||||||
|
reason_code="passed_stage1",
|
||||||
|
)
|
||||||
|
store.update_run_status(first_run, status="failed")
|
||||||
|
|
||||||
|
second_run = store.create_run(profile="tg-hdd", root_paths=(root,))
|
||||||
|
[second_stored] = store.upsert_files(second_run, [candidate])
|
||||||
|
store.update_run_status(second_run, status="running")
|
||||||
|
|
||||||
|
before_size = db_path.stat().st_size
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"compact-db",
|
||||||
|
"--db",
|
||||||
|
str(db_path),
|
||||||
|
"--run-id",
|
||||||
|
str(second_run),
|
||||||
|
"--no-dry-run",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert f"keep_run_id={second_run}" in result.stdout
|
||||||
|
assert "deleted_runs=1" in result.stdout
|
||||||
|
|
||||||
|
with sqlite3.connect(db_path) as connection:
|
||||||
|
run_count = connection.execute("SELECT COUNT(*) FROM scan_runs").fetchone()[0]
|
||||||
|
file_count = connection.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
||||||
|
stage1_count = connection.execute("SELECT COUNT(*) FROM stage1_results").fetchone()[0]
|
||||||
|
remaining_run_id = connection.execute("SELECT id FROM scan_runs").fetchone()[0]
|
||||||
|
|
||||||
|
assert second_stored.file_id != first_stored.file_id
|
||||||
|
assert run_count == 1
|
||||||
|
assert file_count == 1
|
||||||
|
assert stage1_count == 1
|
||||||
|
assert remaining_run_id == second_run
|
||||||
|
assert db_path.stat().st_size <= before_size
|
||||||
|
|
||||||
|
|
||||||
def test_scan_no_resume_forces_probe_execution(tmp_path, monkeypatch) -> None:
|
def test_scan_no_resume_forces_probe_execution(tmp_path, monkeypatch) -> None:
|
||||||
root = tmp_path / "videos"
|
root = tmp_path / "videos"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
|
|||||||
@@ -132,3 +132,100 @@ def test_upsert_files_does_not_reuse_changed_file(tmp_path: Path) -> None:
|
|||||||
assert fresh.stage1_status == "pending_stage1"
|
assert fresh.stage1_status == "pending_stage1"
|
||||||
assert fresh.final_status is None
|
assert fresh.final_status is None
|
||||||
assert fresh.resumed_from_run_id is None
|
assert fresh.resumed_from_run_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_files_same_run_preserves_existing_status_without_duplication(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = ScanStore(tmp_path / "scan_state.db")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
run_id = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||||
|
file_path = tmp_path / "clip.mp4"
|
||||||
|
candidate = make_candidate(file_path, order=0, size=12, mtime_ns=999)
|
||||||
|
[stored] = store.upsert_files(run_id, [candidate])
|
||||||
|
store.record_stage1_result(
|
||||||
|
stored.file_id,
|
||||||
|
exit_code=0,
|
||||||
|
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"}}',
|
||||||
|
stderr_text="",
|
||||||
|
elapsed_ms=10,
|
||||||
|
)
|
||||||
|
store.record_stage1_status(
|
||||||
|
stored.file_id,
|
||||||
|
stage1_status="passed_stage1",
|
||||||
|
final_status="passed_stage1",
|
||||||
|
reason_code="passed_stage1",
|
||||||
|
)
|
||||||
|
|
||||||
|
[resumed] = store.upsert_files(run_id, [candidate])
|
||||||
|
|
||||||
|
assert resumed.file_id == stored.file_id
|
||||||
|
assert resumed.stage1_status == "passed_stage1"
|
||||||
|
assert resumed.final_status == "passed_stage1"
|
||||||
|
assert resumed.resumed_from_run_id is None
|
||||||
|
|
||||||
|
with sqlite3.connect(store.db_path) as connection:
|
||||||
|
file_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM files WHERE run_id = ?",
|
||||||
|
(run_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
stage1_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM stage1_results WHERE file_id = ?",
|
||||||
|
(stored.file_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
assert file_count == 1
|
||||||
|
assert stage1_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_files_same_run_changed_file_clears_stale_stage_results(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = ScanStore(tmp_path / "scan_state.db")
|
||||||
|
store.initialize()
|
||||||
|
|
||||||
|
run_id = store.create_run(profile="tg-hdd", root_paths=(tmp_path,))
|
||||||
|
file_path = tmp_path / "clip.mp4"
|
||||||
|
original = make_candidate(file_path, order=0, size=12, mtime_ns=999)
|
||||||
|
[stored] = store.upsert_files(run_id, [original])
|
||||||
|
store.record_stage1_result(
|
||||||
|
stored.file_id,
|
||||||
|
exit_code=0,
|
||||||
|
stdout_text='{"format":{"format_name":"mp4","duration":"2.0"}}',
|
||||||
|
stderr_text="",
|
||||||
|
elapsed_ms=10,
|
||||||
|
)
|
||||||
|
store.record_stage1_status(
|
||||||
|
stored.file_id,
|
||||||
|
stage1_status="passed_stage1",
|
||||||
|
final_status="passed_stage1",
|
||||||
|
reason_code="passed_stage1",
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = FileCandidate(
|
||||||
|
path=file_path,
|
||||||
|
size=13,
|
||||||
|
mtime_ns=1000,
|
||||||
|
extension="mp4",
|
||||||
|
discovery_order=0,
|
||||||
|
)
|
||||||
|
[refreshed] = store.upsert_files(run_id, [changed])
|
||||||
|
|
||||||
|
assert refreshed.file_id == stored.file_id
|
||||||
|
assert refreshed.stage1_status == "pending_stage1"
|
||||||
|
assert refreshed.final_status is None
|
||||||
|
assert refreshed.reason_code is None
|
||||||
|
|
||||||
|
with sqlite3.connect(store.db_path) as connection:
|
||||||
|
stage1_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM stage1_results WHERE file_id = ?",
|
||||||
|
(stored.file_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
stage2_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM stage2_results WHERE file_id = ?",
|
||||||
|
(stored.file_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
assert stage1_count == 0
|
||||||
|
assert stage2_count == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user