595 lines
23 KiB
Python
595 lines
23 KiB
Python
"""Migrate LT1/Sentinel-1 source records from unpacked pools to ZIP archives.
|
|
|
|
Default mode is a dry run. Use --apply to sync configured roots and scan ZIP
|
|
source pools. Use --quarantine-old-pools only after the database and preview
|
|
checks pass.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Sequence
|
|
|
|
from sqlalchemy import and_, case, exists, func, or_, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from backend.app import database # noqa: E402
|
|
from backend.app.config import settings, split_env_paths # noqa: E402
|
|
from backend.app.models import ManagedRootORM, RadarDataORM, SourceProductAssetORM # noqa: E402
|
|
from backend.app.services.asset_inventory_service import asset_inventory_service # noqa: E402
|
|
from backend.app.services.data_service import DataService # noqa: E402
|
|
from backend.app.services.image_service import image_service # noqa: E402
|
|
from backend.app.services.root_registry_service import root_registry_service # noqa: E402
|
|
|
|
|
|
OLD_LT1_POOL = r"D:\LuTan1_Image_Pool"
|
|
OLD_S1_POOL = r"D:\Sentinel1_Image_Pool"
|
|
|
|
|
|
def _norm(path: str | Path) -> str:
|
|
text = str(path or "").strip()
|
|
if not text:
|
|
return ""
|
|
return os.path.normpath(os.path.abspath(text))
|
|
|
|
|
|
def _normcase(path: str | Path) -> str:
|
|
return os.path.normcase(_norm(path))
|
|
|
|
|
|
def _path_under(path: str | Path, root: str | Path) -> bool:
|
|
path_norm = _normcase(path)
|
|
root_norm = _normcase(root)
|
|
return path_norm == root_norm or path_norm.startswith(root_norm + os.sep)
|
|
|
|
|
|
def _human_bytes(value: int | None) -> str:
|
|
size = float(value or 0)
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if size < 1024 or unit == "TB":
|
|
return f"{size:.2f} {unit}"
|
|
size /= 1024
|
|
return f"{size:.2f} TB"
|
|
|
|
|
|
def _measure_top_level(path: str) -> Dict[str, Any]:
|
|
root = Path(path)
|
|
if not root.exists():
|
|
return {
|
|
"path": path,
|
|
"exists": False,
|
|
"top_level_items": 0,
|
|
"sample": [],
|
|
}
|
|
sample = []
|
|
top_level_items = 0
|
|
try:
|
|
for item in root.iterdir():
|
|
top_level_items += 1
|
|
if len(sample) < 10:
|
|
sample.append(str(item))
|
|
except OSError as exc:
|
|
return {
|
|
"path": path,
|
|
"exists": True,
|
|
"error": str(exc),
|
|
"top_level_items": top_level_items,
|
|
"sample": sample,
|
|
}
|
|
return {
|
|
"path": path,
|
|
"exists": True,
|
|
"top_level_items": top_level_items,
|
|
"sample": sample,
|
|
}
|
|
|
|
|
|
async def _count_scalar(db: AsyncSession, stmt) -> int:
|
|
return int((await db.execute(stmt)).scalar_one() or 0)
|
|
|
|
|
|
def _path_prefix_clauses(column: Any, roots: Sequence[str]) -> List[Any]:
|
|
clauses: List[Any] = []
|
|
for root in roots:
|
|
text = str(root or "").strip()
|
|
if not text:
|
|
continue
|
|
clauses.append(
|
|
and_(
|
|
func.lower(func.substr(column, 1, len(text))) == text.lower(),
|
|
or_(
|
|
func.length(column) == len(text),
|
|
func.substr(column, len(text) + 1, 1).in_(["\\", "/"]),
|
|
),
|
|
)
|
|
)
|
|
return clauses
|
|
|
|
|
|
def _path_prefix_filter(column: Any, roots: Sequence[str]) -> Any:
|
|
clauses = _path_prefix_clauses(column, roots)
|
|
if not clauses:
|
|
return None
|
|
return or_(*clauses)
|
|
|
|
|
|
async def _db_summary(db: AsyncSession, old_pools: Sequence[str], zip_roots: Sequence[str]) -> Dict[str, Any]:
|
|
old_filter = _path_prefix_filter(RadarDataORM.file_path, old_pools)
|
|
zip_filter = _path_prefix_filter(RadarDataORM.file_path, zip_roots)
|
|
|
|
radar_by_family = {}
|
|
family_rows = await db.execute(
|
|
select(RadarDataORM.satellite_family, func.count(RadarDataORM.id))
|
|
.where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
.group_by(RadarDataORM.satellite_family)
|
|
)
|
|
for family, count in family_rows.all():
|
|
radar_by_family[str(family or "")] = int(count or 0)
|
|
|
|
radar_by_source_format = {}
|
|
format_rows = await db.execute(
|
|
select(RadarDataORM.source_format, func.count(RadarDataORM.id))
|
|
.where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
.group_by(RadarDataORM.source_format)
|
|
)
|
|
for source_format, count in format_rows.all():
|
|
radar_by_source_format[str(source_format or "NULL")] = int(count or 0)
|
|
|
|
assets_by_format = {}
|
|
asset_rows = await db.execute(
|
|
select(SourceProductAssetORM.source_format, func.count(SourceProductAssetORM.id))
|
|
.where(SourceProductAssetORM.satellite_family.in_(["LT1", "S1"]))
|
|
.group_by(SourceProductAssetORM.source_format)
|
|
)
|
|
for source_format, count in asset_rows.all():
|
|
assets_by_format[str(source_format or "NULL")] = int(count or 0)
|
|
|
|
preview_rows = await db.execute(
|
|
select(
|
|
func.count(RadarDataORM.id),
|
|
func.sum(case((RadarDataORM.preview_cache_status == "READY", 1), else_=0)),
|
|
func.sum(case((RadarDataORM.preview_cache_path.is_not(None), 1), else_=0)),
|
|
).where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
)
|
|
total_previews, ready_previews, path_previews = preview_rows.one()
|
|
|
|
summary: Dict[str, Any] = {
|
|
"radar_by_family": radar_by_family,
|
|
"radar_by_source_format": radar_by_source_format,
|
|
"source_assets_by_format": assets_by_format,
|
|
"preview_rows": {
|
|
"total": int(total_previews or 0),
|
|
"ready": int(ready_previews or 0),
|
|
"has_cache_path": int(path_previews or 0),
|
|
},
|
|
}
|
|
if old_filter is not None:
|
|
summary["radar_file_paths_under_old_pools"] = await _count_scalar(
|
|
db,
|
|
select(func.count(RadarDataORM.id)).where(old_filter),
|
|
)
|
|
if zip_filter is not None:
|
|
summary["radar_file_paths_under_zip_roots"] = await _count_scalar(
|
|
db,
|
|
select(func.count(RadarDataORM.id)).where(zip_filter),
|
|
)
|
|
return summary
|
|
|
|
|
|
async def _archive_migration_gap_summary(db: AsyncSession, old_pools: Sequence[str]) -> Dict[str, Any]:
|
|
old_filter = _path_prefix_filter(RadarDataORM.file_path, old_pools)
|
|
if old_filter is None:
|
|
return {"old_pool_records": 0}
|
|
|
|
def _archive_exists(family: str, source_format: str) -> Any:
|
|
return exists(
|
|
select(SourceProductAssetORM.id).where(
|
|
SourceProductAssetORM.satellite_family == family,
|
|
SourceProductAssetORM.source_format == source_format,
|
|
SourceProductAssetORM.logical_product_uid == RadarDataORM.product_unique_id,
|
|
SourceProductAssetORM.is_active == True, # noqa: E712
|
|
)
|
|
)
|
|
|
|
lt1_archive_exists = _archive_exists("LT1", "LT1_ARCHIVE")
|
|
s1_archive_exists = _archive_exists("S1", "S1_ZIP")
|
|
base_filter = and_(
|
|
old_filter,
|
|
RadarDataORM.satellite_family.in_(["LT1", "S1"]),
|
|
RadarDataORM.source_format.in_(["LT1_DIR", "S1_SAFE_DIR"]),
|
|
)
|
|
migrateable_filter = and_(
|
|
base_filter,
|
|
or_(
|
|
and_(RadarDataORM.satellite_family == "LT1", lt1_archive_exists),
|
|
and_(RadarDataORM.satellite_family == "S1", s1_archive_exists),
|
|
),
|
|
)
|
|
missing_filter = and_(
|
|
base_filter,
|
|
or_(
|
|
and_(RadarDataORM.satellite_family == "LT1", ~lt1_archive_exists),
|
|
and_(RadarDataORM.satellite_family == "S1", ~s1_archive_exists),
|
|
),
|
|
)
|
|
|
|
samples = await db.execute(
|
|
select(
|
|
RadarDataORM.id,
|
|
RadarDataORM.satellite_family,
|
|
RadarDataORM.product_unique_id,
|
|
RadarDataORM.file_path,
|
|
)
|
|
.where(missing_filter)
|
|
.order_by(RadarDataORM.id.asc())
|
|
.limit(10)
|
|
)
|
|
return {
|
|
"old_pool_records": await _count_scalar(db, select(func.count(RadarDataORM.id)).where(base_filter)),
|
|
"migrateable_with_archive_asset": await _count_scalar(db, select(func.count(RadarDataORM.id)).where(migrateable_filter)),
|
|
"missing_archive_asset": await _count_scalar(db, select(func.count(RadarDataORM.id)).where(missing_filter)),
|
|
"missing_archive_samples": [
|
|
{
|
|
"radar_id": radar_id,
|
|
"family": family,
|
|
"product_unique_id": product_unique_id,
|
|
"file_path": file_path,
|
|
}
|
|
for radar_id, family, product_unique_id, file_path in samples.all()
|
|
],
|
|
}
|
|
|
|
|
|
async def _list_zip_roots(db: AsyncSession) -> List[Dict[str, Any]]:
|
|
result = await db.execute(
|
|
select(ManagedRootORM)
|
|
.where(ManagedRootORM.root_role == "source_product_pool")
|
|
.order_by(ManagedRootORM.id.asc())
|
|
)
|
|
roots = []
|
|
for root in result.scalars().all():
|
|
roots.append(
|
|
{
|
|
"id": root.id,
|
|
"path": root.path,
|
|
"enabled": bool(root.enabled),
|
|
"exists": bool(root.exists_flag),
|
|
"source_ref": root.source_ref,
|
|
}
|
|
)
|
|
return roots
|
|
|
|
|
|
async def _target_scan_root_ids(db: AsyncSession, *, bind_orbits: bool) -> List[int]:
|
|
source_result = await db.execute(
|
|
select(ManagedRootORM.id).where(
|
|
ManagedRootORM.enabled == True, # noqa: E712
|
|
ManagedRootORM.root_role == "source_product_pool",
|
|
ManagedRootORM.source_ref.like("SOURCE_PRODUCT_DIRS%"),
|
|
)
|
|
)
|
|
root_ids = [int(item) for item in source_result.scalars().all()]
|
|
if bind_orbits:
|
|
orbit_result = await db.execute(
|
|
select(ManagedRootORM.id).where(
|
|
ManagedRootORM.enabled == True, # noqa: E712
|
|
ManagedRootORM.root_role == "orbit_asset_pool",
|
|
)
|
|
)
|
|
root_ids.extend(int(item) for item in orbit_result.scalars().all())
|
|
return root_ids
|
|
|
|
|
|
async def _sample_preview_archive_sources(db: AsyncSession, limit: int) -> List[Dict[str, Any]]:
|
|
result = await db.execute(
|
|
select(RadarDataORM)
|
|
.where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
.where(RadarDataORM.source_format.in_(["LT1_ARCHIVE", "S1_ZIP"]))
|
|
.order_by(RadarDataORM.id.asc())
|
|
.limit(max(0, int(limit)))
|
|
)
|
|
samples: List[Dict[str, Any]] = []
|
|
for record in result.scalars().all():
|
|
preview_source = DataService.find_radar_preview_source(record.file_path)
|
|
geo_path = DataService.get_radar_geo_cache_path(record.unique_id or record.file_path, record.file_path)
|
|
samples.append(
|
|
{
|
|
"radar_id": record.id,
|
|
"family": record.satellite_family,
|
|
"source_format": record.source_format,
|
|
"file_path": record.file_path,
|
|
"preview_source_found": bool(preview_source),
|
|
"preview_source": preview_source,
|
|
"db_preview_ready": (record.preview_cache_status or "") == "READY",
|
|
"db_preview_cache_exists": bool(record.preview_cache_path and os.path.exists(record.preview_cache_path)),
|
|
"expected_geo_cache_exists": os.path.exists(geo_path),
|
|
}
|
|
)
|
|
return samples
|
|
|
|
|
|
async def _build_archive_previews(
|
|
db: AsyncSession,
|
|
*,
|
|
limit: int,
|
|
force: bool,
|
|
) -> Dict[str, Any]:
|
|
stmt = (
|
|
select(RadarDataORM)
|
|
.where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
.where(RadarDataORM.source_format.in_(["LT1_ARCHIVE", "S1_ZIP"]))
|
|
.order_by(RadarDataORM.id.asc())
|
|
)
|
|
if not force:
|
|
stmt = stmt.where(
|
|
or_(
|
|
RadarDataORM.preview_cache_status != "READY",
|
|
RadarDataORM.preview_cache_status.is_(None),
|
|
RadarDataORM.preview_cache_path.is_(None),
|
|
)
|
|
)
|
|
if limit > 0:
|
|
stmt = stmt.limit(limit)
|
|
|
|
result = await db.execute(stmt)
|
|
records = result.scalars().all()
|
|
summary = {
|
|
"candidate_count": len(records),
|
|
"ready": 0,
|
|
"failed": 0,
|
|
"missing_source": 0,
|
|
"skipped_invalid_geometry": 0,
|
|
"items": [],
|
|
}
|
|
thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE)
|
|
for record in records:
|
|
unique_id = record.unique_id or record.file_path
|
|
raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path)
|
|
geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, record.file_path)
|
|
preview_source = DataService.find_radar_preview_source(record.file_path)
|
|
item: Dict[str, Any] = {
|
|
"radar_id": record.id,
|
|
"family": record.satellite_family,
|
|
"source_format": record.source_format,
|
|
"file_path": record.file_path,
|
|
"preview_source": preview_source,
|
|
}
|
|
if not preview_source:
|
|
record.preview_cache_status = "NONE"
|
|
record.preview_cache_path = None
|
|
record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION
|
|
record.preview_cache_updated_at = datetime.utcnow()
|
|
record.preview_cache_error = "preview_source_not_found"
|
|
db.add(record)
|
|
summary["missing_source"] += 1
|
|
item["status"] = "missing_source"
|
|
summary["items"].append(item)
|
|
continue
|
|
|
|
coverage_polygon = DataService._normalize_coverage_polygon(record.coverage_polygon)
|
|
try:
|
|
bbox = (
|
|
float(record.min_lon),
|
|
float(record.min_lat),
|
|
float(record.max_lon),
|
|
float(record.max_lat),
|
|
)
|
|
except (TypeError, ValueError):
|
|
bbox = None
|
|
if not coverage_polygon or not bbox:
|
|
record.preview_cache_status = "FAILED"
|
|
record.preview_cache_path = None
|
|
record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION
|
|
record.preview_cache_updated_at = datetime.utcnow()
|
|
record.preview_cache_error = "invalid_coverage_polygon" if not coverage_polygon else "invalid_bbox"
|
|
db.add(record)
|
|
summary["skipped_invalid_geometry"] += 1
|
|
item["status"] = record.preview_cache_error
|
|
summary["items"].append(item)
|
|
continue
|
|
|
|
source_corner_mapping = DataService.get_radar_source_corner_mapping(record.file_path)
|
|
ok_geo, geo_error = image_service.create_geocorrected_radar_cached_image(
|
|
preview_source,
|
|
geo_cache_path,
|
|
coverage_polygon,
|
|
bbox,
|
|
source_corner_mapping,
|
|
thumb_size,
|
|
settings.RADAR_GEO_CACHE_QUALITY,
|
|
)
|
|
ok_raw = image_service.create_radar_cached_image(preview_source, raw_cache_path, thumb_size)
|
|
if ok_geo and os.path.exists(geo_cache_path):
|
|
record.preview_cache_status = "READY"
|
|
record.preview_cache_path = geo_cache_path
|
|
record.preview_cache_error = None
|
|
summary["ready"] += 1
|
|
item["status"] = "ready"
|
|
item["geo_cache_path"] = geo_cache_path
|
|
else:
|
|
record.preview_cache_status = "FAILED"
|
|
record.preview_cache_path = None
|
|
record.preview_cache_error = geo_error or ("raw_cache_ready_only" if ok_raw else "preview_cache_build_failed")
|
|
summary["failed"] += 1
|
|
item["status"] = "failed"
|
|
item["error"] = record.preview_cache_error
|
|
record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION
|
|
record.preview_cache_updated_at = datetime.utcnow()
|
|
db.add(record)
|
|
summary["items"].append(item)
|
|
await db.commit()
|
|
return summary
|
|
|
|
|
|
async def _count_duplicate_products(db: AsyncSession) -> List[Dict[str, Any]]:
|
|
result = await db.execute(
|
|
select(
|
|
RadarDataORM.satellite_family,
|
|
RadarDataORM.product_unique_id,
|
|
func.count(RadarDataORM.id).label("count"),
|
|
)
|
|
.where(RadarDataORM.satellite_family.in_(["LT1", "S1"]))
|
|
.where(RadarDataORM.product_unique_id.is_not(None))
|
|
.group_by(RadarDataORM.satellite_family, RadarDataORM.product_unique_id)
|
|
.having(func.count(RadarDataORM.id) > 1)
|
|
.order_by(func.count(RadarDataORM.id).desc())
|
|
.limit(50)
|
|
)
|
|
return [
|
|
{
|
|
"family": family,
|
|
"product_unique_id": product_unique_id,
|
|
"count": int(count or 0),
|
|
}
|
|
for family, product_unique_id, count in result.all()
|
|
]
|
|
|
|
|
|
async def _mark_old_assets_inactive(db: AsyncSession, old_pools: Sequence[str]) -> Dict[str, int]:
|
|
if not old_pools:
|
|
return {"source_assets": 0}
|
|
old_filter = _path_prefix_filter(SourceProductAssetORM.file_path, old_pools)
|
|
if old_filter is None:
|
|
return {"source_assets": 0}
|
|
result = await db.execute(
|
|
update(SourceProductAssetORM)
|
|
.where(old_filter)
|
|
.where(SourceProductAssetORM.source_format.in_(["LT1_DIR", "S1_SAFE_DIR"]))
|
|
.values(is_active=False, missing_since=datetime.utcnow())
|
|
.execution_options(synchronize_session=False)
|
|
)
|
|
return {"source_assets": int(result.rowcount or 0)}
|
|
|
|
|
|
def _quarantine_old_pools(old_pools: Iterable[str], suffix: str) -> List[Dict[str, Any]]:
|
|
results: List[Dict[str, Any]] = []
|
|
for raw_path in old_pools:
|
|
path = _norm(raw_path)
|
|
if not path:
|
|
continue
|
|
target = f"{path}.{suffix}"
|
|
result: Dict[str, Any] = {
|
|
"source": path,
|
|
"target": target,
|
|
"source_exists": os.path.exists(path),
|
|
"target_exists": os.path.exists(target),
|
|
}
|
|
if not os.path.exists(path):
|
|
results.append(result)
|
|
continue
|
|
if os.path.exists(target):
|
|
result["status"] = "skipped_target_exists"
|
|
results.append(result)
|
|
continue
|
|
if Path(path).anchor == path:
|
|
raise ValueError(f"Refusing to quarantine drive root: {path}")
|
|
shutil.move(path, target)
|
|
result["status"] = "moved"
|
|
results.append(result)
|
|
return results
|
|
|
|
|
|
async def _run(args: argparse.Namespace) -> Dict[str, Any]:
|
|
if database.AsyncSessionLocal is None:
|
|
database.init_db(settings.DATABASE_URL)
|
|
if database.AsyncSessionLocal is None:
|
|
raise RuntimeError("Database session factory is not initialized")
|
|
|
|
old_pools = [_norm(item) for item in (args.old_pool or []) if str(item or "").strip()]
|
|
zip_roots = [_norm(item) for item in split_env_paths(settings.SOURCE_PRODUCT_DIRS)]
|
|
|
|
async with database.AsyncSessionLocal() as db:
|
|
before = await _db_summary(db, old_pools, zip_roots)
|
|
result: Dict[str, Any] = {
|
|
"apply": bool(args.apply),
|
|
"old_pools": [_measure_top_level(path) for path in old_pools],
|
|
"source_product_dirs": zip_roots,
|
|
"before": before,
|
|
"archive_migration_gap_before": await _archive_migration_gap_summary(db, old_pools),
|
|
}
|
|
|
|
if args.apply:
|
|
result["root_registry_sync"] = await root_registry_service.sync_from_settings(db)
|
|
if args.scan_archives:
|
|
root_ids = await _target_scan_root_ids(db, bind_orbits=bool(args.bind_orbits))
|
|
result["asset_inventory_scan"] = await asset_inventory_service.scan_configured_roots(
|
|
db,
|
|
inventory_types=["source_product", "orbit_asset"] if args.bind_orbits else ["source_product"],
|
|
root_ids=root_ids,
|
|
bind_orbits=bool(args.bind_orbits),
|
|
)
|
|
if args.build_archive_previews:
|
|
result["archive_preview_build"] = await _build_archive_previews(
|
|
db,
|
|
limit=max(0, int(args.preview_build_limit)),
|
|
force=bool(args.force_preview_rebuild),
|
|
)
|
|
if args.mark_old_assets_inactive:
|
|
result["marked_old_assets_inactive"] = await _mark_old_assets_inactive(db, old_pools)
|
|
await db.commit()
|
|
else:
|
|
result["planned_actions"] = [
|
|
"sync managed roots from .env",
|
|
"scan SOURCE_PRODUCT_DIRS for LT1_ARCHIVE/S1_ZIP",
|
|
"upsert radar_data by logical product id",
|
|
]
|
|
if args.mark_old_assets_inactive:
|
|
result["planned_actions"].append("mark old LT1_DIR/S1_SAFE_DIR source assets inactive")
|
|
|
|
result["roots"] = await _list_zip_roots(db)
|
|
result["after"] = await _db_summary(db, old_pools, zip_roots)
|
|
result["archive_migration_gap_after"] = await _archive_migration_gap_summary(db, old_pools)
|
|
result["duplicate_products"] = await _count_duplicate_products(db)
|
|
if args.preview_sample > 0:
|
|
result["preview_archive_samples"] = await _sample_preview_archive_sources(db, args.preview_sample)
|
|
|
|
if args.quarantine_old_pools:
|
|
if not args.apply:
|
|
result["quarantine"] = {"skipped": True, "reason": "requires --apply"}
|
|
elif result["after"].get("radar_file_paths_under_old_pools", 0) > 0:
|
|
result["quarantine"] = {
|
|
"skipped": True,
|
|
"reason": "database still has radar_data.file_path under old pools",
|
|
}
|
|
else:
|
|
result["quarantine"] = _quarantine_old_pools(old_pools, args.quarantine_suffix)
|
|
|
|
return result
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--apply", action="store_true", help="write database changes and run scans")
|
|
parser.add_argument("--scan-archives", action="store_true", default=True, help="scan configured source product roots")
|
|
parser.add_argument("--no-scan-archives", action="store_false", dest="scan_archives")
|
|
parser.add_argument("--bind-orbits", action="store_true", default=True, help="bind scenes to orbit assets after scan")
|
|
parser.add_argument("--no-bind-orbits", action="store_false", dest="bind_orbits")
|
|
parser.add_argument("--mark-old-assets-inactive", action="store_true", help="mark old LT1_DIR/S1_SAFE_DIR assets inactive")
|
|
parser.add_argument("--preview-sample", type=int, default=5, help="try archive preview extraction on N migrated records")
|
|
parser.add_argument("--build-archive-previews", action="store_true", help="build WebP preview caches for LT1_ARCHIVE/S1_ZIP records")
|
|
parser.add_argument("--preview-build-limit", type=int, default=0, help="limit preview cache builds; 0 means no limit")
|
|
parser.add_argument("--force-preview-rebuild", action="store_true", help="rebuild archive preview caches even if READY")
|
|
parser.add_argument("--quarantine-old-pools", action="store_true", help="rename old unpacked pools after migration checks pass")
|
|
parser.add_argument("--quarantine-suffix", default="__quarantine_20260616", help="suffix appended to old pool directory names")
|
|
parser.add_argument("--old-pool", action="append", default=[OLD_LT1_POOL, OLD_S1_POOL], help="old unpacked source pool path")
|
|
args = parser.parse_args()
|
|
|
|
payload = asyncio.run(_run(args))
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|