Spaces:
Running
Running
File size: 12,862 Bytes
d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | """File storage utilities — streaming writes, SHA256, cleanup."""
import os
import json
import hashlib
import tempfile
import shutil
import asyncio
from pathlib import Path, PurePosixPath
from datetime import datetime, timezone
from fastapi import UploadFile, HTTPException
from uuid import uuid4
from app.config import settings
from app.remote_storage import remote_storage
ALLOWED_EXTENSIONS = settings.ALLOWED_VIDEO_EXTENSIONS | settings.ALLOWED_IMAGE_EXTENSIONS
def _rel_path(subdir: str, filename: str) -> str:
"""Build a date-based relative path: <subdir>/YYYY/MM/DD/<filename>"""
now = datetime.now(timezone.utc)
date_path = now.strftime("%Y/%m/%d")
return f"{subdir}/{date_path}/{filename}"
def _abs_path(rel_path: str) -> Path:
return settings.storage_root / rel_path
def _ensure_dir(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def _validate_extension(filename: str) -> str:
"""Validate and return the file extension (lowercase, no dot)."""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if not ext:
raise HTTPException(status_code=400, detail="File has no extension")
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"Extension .{ext} not allowed. Allowed: {ALLOWED_EXTENSIONS}")
return ext
class FileSaveResult:
def __init__(self, relative_path: str, size: int, sha256: str):
self.relative_path = relative_path
self.size = size
self.sha256 = sha256
async def save_upload(file: UploadFile, subdir: str = "raw_images") -> FileSaveResult:
"""Stream an UploadFile to disk, compute SHA-256, return result."""
ext = _validate_extension(file.filename or "unknown.bin")
unique_name = f"{uuid4().hex}.{ext}"
rel_path = _rel_path(subdir, unique_name)
abs_path = _abs_path(rel_path)
_ensure_dir(abs_path)
sha = hashlib.sha256()
size = 0
# Write to temp file first, then atomic rename
tmp = tempfile.NamedTemporaryFile(dir=abs_path.parent, delete=False)
try:
while chunk := await file.read(1024 * 1024): # 1MB chunks
sha.update(chunk)
tmp.write(chunk)
size += len(chunk)
if size > settings.MAX_UPLOAD_SIZE:
tmp.close()
os.unlink(tmp.name)
raise HTTPException(status_code=413, detail="File too large")
except HTTPException:
raise
except Exception as e:
tmp.close()
os.unlink(tmp.name)
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
finally:
tmp.close()
os.replace(tmp.name, str(abs_path))
remote = remote_storage()
if remote is not None:
await asyncio.to_thread(remote.upload_file, abs_path, rel_path)
return FileSaveResult(relative_path=rel_path, size=size, sha256=sha.hexdigest())
def delete_file(relative_path: str) -> None:
"""Delete a file by its relative path."""
abs_path = _abs_path(relative_path)
if abs_path.exists():
abs_path.unlink()
remote = remote_storage()
if remote is not None:
remote.remove_file(relative_path)
def sync_tree_to_remote(local_root: Path, remote_relative: str | Path) -> None:
"""Mirror a local tree to SFTP; no-op for local storage."""
remote = remote_storage()
if remote is not None:
remote.upload_tree(local_root, remote_relative)
def sync_file_to_remote(local_path: Path, remote_relative: str | Path) -> None:
remote = remote_storage()
if remote is not None:
remote.upload_file(local_path, remote_relative)
async def sync_file_to_remote_async(local_path: Path, remote_relative: str | Path) -> None:
await asyncio.to_thread(sync_file_to_remote, local_path, remote_relative)
async def ensure_local_file(relative_path: str | Path) -> Path | None:
"""Materialize one remote file into the local cache when needed."""
raw = str(relative_path).replace("\\", "/")
safe = PurePosixPath(raw)
if safe.is_absolute() or ".." in safe.parts:
raise ValueError(f"Unsafe storage path: {relative_path}")
local_path = settings.storage_root / Path(*safe.parts)
if local_path.exists():
return local_path
remote = remote_storage()
if remote is None:
return None
await asyncio.to_thread(remote.download_file, relative_path, local_path)
return local_path if local_path.exists() else None
async def sync_tree_to_remote_async(local_root: Path, remote_relative: str | Path) -> None:
await asyncio.to_thread(sync_tree_to_remote, local_root, remote_relative)
def delete_tree(remote_relative: str | Path, local_root: Path | None = None) -> None:
"""Delete a session/result tree from cache and authoritative storage."""
if local_root is not None and local_root.exists():
shutil.rmtree(local_root, ignore_errors=True)
remote = remote_storage()
if remote is not None:
remote.remove_tree(remote_relative)
def storage_ok() -> bool:
"""Check that the storage directory is writable."""
try:
remote = remote_storage()
if remote is not None:
return remote.check()
probe = settings.storage_root / ".health_probe"
probe.touch()
probe.unlink()
return True
except OSError:
return False
def scan_existing_mp4(episode_id: str, camera: str) -> str | None:
"""Find an existing MP4 video file for an episode/camera.
Searches the raw_videos directory by episode_id prefix.
"""
raw_dir = settings.storage_root / "videos"
# Look for compiled MP4 or individual frames
mp4_file = raw_dir / f"{episode_id}_{camera}.mp4"
if mp4_file.exists():
return str(mp4_file)
# Check date-organized directories
for root, _, files in os.walk(str(raw_dir)):
for f in files:
if f.startswith(episode_id[:8]) and camera in f and f.endswith(".mp4"):
return str(Path(root) / f)
return None
def find_session_dir(session_id: str, db=None) -> Path | None:
"""Find session directory under sessions/.
- New: looks up Session.original_archive (e.g. 'test00/test00_20260806_102355')
- Current layout: sessions/<project>/<task>/ — walk two levels
- Old: walks for directory named {session_id}
"""
sessions_root = settings.storage_root / "sessions"
if not sessions_root.exists():
return None
sid = str(session_id)
# Walk: project/task-organized structure
for project_dir in sessions_root.iterdir():
if project_dir.is_dir():
candidate = project_dir / sid
if candidate.is_dir():
return candidate
# Project-level LeRobot storage keeps all episodes directly under
# sessions/<project>/{data,meta,videos}; resolve the episode ID
# from the project metadata instead of expecting a batch folder.
if (project_dir / "meta" / "episodes").is_dir():
try:
from app.project_dataset import project_episode_rows
for row in project_episode_rows(project_dir):
if (str(row.get("episode_id") or row.get("source_batch") or "") == sid
or str(row.get("episode_index") or "") == sid):
return project_dir
except (OSError, ValueError, TypeError):
pass
# Old flat structure
candidate = sessions_root / sid
if candidate.is_dir():
return candidate
return None
# ── Session dir cache (avoids repeated filesystem walks) ──
_session_dir_cache: dict[str, Path | None] = {}
# None 结果的缓存时间戳 —— SFTP 下载可能稍后才完成(或暂时失败),
# 若把 None 永久缓存,下载完成后也永远读不到该 session。
_session_dir_cache_none_at: dict[str, float] = {}
_SESSION_DIR_NONE_TTL = 30.0
def _session_cache_ready(path: Path) -> bool:
"""A session cache is usable only when it contains downloaded files."""
return path.is_dir() and any(item.is_file() for item in path.rglob("*"))
async def find_session_dir_async(session_id: str, db) -> Path | None:
"""Async version: looks up Session.original_archive for the path.
Result is cached — session directories don't move after creation.
Negative (None) results expire after a short TTL so a later SFTP
download of the session tree is picked up without a backend restart.
"""
sid = str(session_id)
if sid in _session_dir_cache:
cached = _session_dir_cache[sid]
if cached is not None:
if _session_cache_ready(cached):
return cached
del _session_dir_cache[sid] # stale entry
else:
# 负面缓存:TTL 内直接返回 None,超时后重新查找
import time as _time
cached_at = _session_dir_cache_none_at.get(sid, 0.0)
if _time.monotonic() - cached_at < _SESSION_DIR_NONE_TTL:
return None
del _session_dir_cache[sid]
_session_dir_cache_none_at.pop(sid, None)
from uuid import UUID
from app.models import Session
sessions_root = settings.storage_root / "sessions"
# Try DB lookup for stored path
original_archive = None
try:
sess = await db.get(Session, UUID(sid))
if sess:
original_archive = sess.original_archive
if original_archive:
candidate = sessions_root / original_archive
if _session_cache_ready(candidate):
_session_dir_cache[sid] = candidate
return candidate
except Exception:
pass
remote = remote_storage()
if remote is not None and original_archive:
candidate = sessions_root / original_archive
await asyncio.to_thread(
remote.download_tree,
Path("sessions") / original_archive,
candidate,
)
if candidate.is_dir():
_session_dir_cache[sid] = candidate
return candidate
# Fallback: walk filesystem (project/task two-level layout)
for project_dir in sessions_root.iterdir():
if project_dir.is_dir():
candidate = project_dir / sid
if _session_cache_ready(candidate):
_session_dir_cache[sid] = candidate
return candidate
if (project_dir / "meta" / "episodes").is_dir():
try:
from app.project_dataset import project_episode_rows
for row in project_episode_rows(project_dir):
if (str(row.get("episode_id") or row.get("source_batch") or "") == sid
or str(row.get("episode_index") or "") == sid):
_session_dir_cache[sid] = project_dir
return project_dir
except (OSError, ValueError, TypeError):
pass
candidate = sessions_root / sid
if _session_cache_ready(candidate):
_session_dir_cache[sid] = candidate
return candidate
import time as _time
_session_dir_cache[sid] = None
_session_dir_cache_none_at[sid] = _time.monotonic()
return None
def sanitize_task_name(name: str) -> str:
"""Sanitize task name for folder use. Preserves Chinese, replaces unsafe chars."""
unsafe = r'[\\/:*?"<>|]'
import re
name = re.sub(unsafe, '', name)
name = name.replace(' ', '_')
name = name.strip('. ')
return name or 'default_recording'
def parse_zip_filename(filename: str) -> dict:
"""Parse zip filename like 'session_20260805_192922.zip'.
Returns dict with:
- prefix: task prefix ('session')
- basename: filename without .zip ('session_20260805_192922')
- date_str: '2026-08-05'
- time_str: '19:29:22'
- timestamp: '2026/8/5 19:29:22' (display format)
"""
import re
name = filename.rsplit('.', 1)[0] # remove extension
result = {'prefix': name, 'basename': name, 'timestamp': name}
# Match pattern: {prefix}_{YYYYMMDD}_{HHMMSS}
m = re.match(r'^(.+?)_(\d{8})_(\d{6})$', name)
if m:
prefix = m.group(1)
date_part = m.group(2) # 20260805
time_part = m.group(3) # 192922
result['prefix'] = prefix
result['basename'] = name
try:
y, mo, d = date_part[:4], date_part[4:6], date_part[6:8]
h, mi, s = time_part[:2], time_part[2:4], time_part[4:6]
result['date_str'] = f'{y}-{mo}-{d}'
result['time_str'] = f'{h}:{mi}:{s}'
result['timestamp'] = f'{int(y)}/{int(mo)}/{int(d)} {h}:{mi}:{s}'
except (ValueError, IndexError):
pass
return result
|