Spaces:
Running
Running
File size: 28,943 Bytes
d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef d646dd7 ddb63ef 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 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 | """
Task record persistence — data/tasks.json is the single source of truth.
- 进度分账(多电脑协同,后端聚合口径):
local_count 本机累计完成段数(increment_task_completed 加它)
synced_count 后端已确认的本机段数(水位,只前进不后退)
backend_count 最近一次已知后端全局数(merge/flush 用后端值无条件替换)
completed_count = backend_count + (local_count - synced_count)
—— 派生显示值,字段名保留使 UI 零改动,所有写路径经 _recount() 重算。
- 迁移回填:旧数据没有上述字段时,local/synced/backend 一次性回填为
旧 completed_count(目录扫描兜底)——历史不补报、显示连续;
此后以持久化值为准,上传后删本地文件也不影响进度。
- Task metadata (id, name, description, total_required, etc.) is persisted.
- Backend tasks are merged into the local record, keeping local-only tasks.
- 本机任务(source="local"):
任务页「+ 新建任务」创建,id 前缀 local-,不进后端任务表、不参与进度上报。
* **仅创建者可见**:created_by 记创建时的身份("guest" 或用户名),
filter_by_identity() 对本机任务按 created_by 过滤(缺失时对所有人可见,
兼容手工写的数据);assigned_user 恒为 None,仍然算「公共任务」,
这样 merge_backend_tasks 的账号撤销清除不会删到它(另有显式豁免)。
* **进度只在本机**:local_count 累计,synced_count/backend_count 恒 0,
pending_sync_tasks() 跳过本机任务 —— 服务端没有对应任务,上报必被拒。
* **删除是真删记录**:remove_local_task() 整条移除(后端不会再推送它,
不需要 hidden 墓碑);服务端任务仍走 mark_hidden()。
* **名字即目录即上传项目名**:只允许字母/数字/汉字/下划线/连字符,
保证 <RECORDING_DIR>/<name> 与 task_tag(name) 完全一致。
"""
from __future__ import annotations
import json
import os
import threading
import uuid
from datetime import datetime, timezone
from config import settings
_TASKS_FILE = os.path.join(settings.DATA_DIR, "tasks.json")
# ── 本机任务常量 ──────────────────────────────────────
LOCAL_TASK_PREFIX = "local-"
MAX_TASK_NAME_LEN = 64
MAX_TASK_DESCRIPTION_LEN = 500
MAX_TOTAL_REQUIRED = 100000
# Windows 保留设备名(不能作为目录名;大小写不敏感)
_WINDOWS_RESERVED_NAMES = frozenset(
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
class TaskValidationError(ValueError):
"""本机任务创建/改名被拒。code 供 UI 本地化,不把中文写进核心层。"""
def __init__(self, code: str, detail: str = ""):
super().__init__(code)
self.code = code
self.detail = detail
def __str__(self) -> str: # 日志/调试可读
return f"{self.code}: {self.detail}" if self.detail else self.code
_lock = threading.RLock() # reentrant — merge_backend_tasks calls save_tasks/load_tasks
_SEED_TASKS = [
{
"id": "task_001",
"name": "cylinder_grasping",
"description": "Grasp cylinder parts with right hand, record 16x16 tactile sensor data.",
"status": "pending",
"total_required": 1000,
"assigned_at": "2026-08-07T09:00:00Z",
"params": {"object": "cylinder", "hand": "right"},
},
{
"id": "task_002",
"name": "cube_placement",
"description": "Place cube objects from point A to point B using both hands.",
"status": "pending",
"total_required": 500,
"assigned_at": "2026-08-06T14:30:00Z",
"params": {"object": "cube", "hand": "both"},
},
{
"id": "task_003",
"name": "valve_rotation",
"description": "Rotate circular valve 90 degrees with single hand, record torque and tactile feedback.",
"status": "pending",
"total_required": 800,
"assigned_at": "2026-08-05T11:00:00Z",
"params": {"object": "valve", "hand": "right", "angle": 90},
},
]
# ═══════════════════════════════════════════════════════════
# Public API
# ═══════════════════════════════════════════════════════════
def load_tasks(identity: str | None = None) -> list[dict]:
"""Load all tasks. Filters out hidden tasks.
completed_count 以持久化值为准(increment_task_completed 累计);
文件中缺该字段(旧版本数据)时一次性按目录扫描回填并写回,
此后删会话文件不再影响进度。
Status is auto-computed: pending(0%), in_progress(>0%), completed(100%).
排序: 按 assigned_at 从新到旧;null/空/解析失败排最后。
identity(新增,默认 None 兼容旧调用):
None → 返回全部任务(含他人创建的本机任务,仅供内部/统计调用)
"guest" → 仅公共任务(无 assigned_user 字段)+ 游客创建的本机任务
用户名 → 公共任务 + assigned_user == 该用户的任务
+ 该用户创建的本机任务
注意: 除迁移回填外不写回 tasks.json —— hidden 任务必须保留在文件中,
否则后端再次推送时无法识别为已删除,任务会死而复生。
"""
with _lock:
tasks = _read_raw()
need_backfill = False
for t in tasks:
if _ensure_sync_fields(t):
need_backfill = True
_recount(t)
t["status"] = _compute_status(t)
if need_backfill:
with _lock:
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
visible = [t for t in tasks if not t.get("hidden")]
visible = filter_by_identity(visible, identity)
visible.sort(key=_assigned_at_key, reverse=True) # 新→旧;null/空/非法排最后
return visible
def filter_by_identity(tasks: list[dict], identity: str | None = None) -> list[dict]:
"""按身份过滤任务列表(纯函数,供 UI 对已有列表即时过滤)。
identity 语义与 load_tasks 相同;返回新列表,不修改原列表。
本机任务按 **created_by** 过滤(仅创建者可见);assigned_user 语义只
用于服务端任务(本机任务 assigned_user 恒为 None)。created_by 缺失的
本机任务视为「无主」,对所有身份可见(兼容手工写的 tasks.json)。
"""
if identity is None:
return list(tasks)
out: list[dict] = []
for t in tasks:
if is_local_task(t):
if _local_visible(t, identity):
out.append(t)
continue
if identity == "guest":
if not _assigned_user(t):
out.append(t)
elif not _assigned_user(t) or _assigned_user(t) == identity:
out.append(t)
return out
def save_tasks(tasks: list[dict]):
"""Persist task metadata,含 completed_count(录制完成次数持久化权威值)。"""
clean = [dict(t) for t in tasks]
with _lock:
_write({"tasks": clean, "updated_at": datetime.now().isoformat()})
# ═══════════════════════════════════════════════════════════
# 本机任务(采集端自建)—— 创建 / 改名 / 删除 / 校验
# ═══════════════════════════════════════════════════════════
def is_local_task(task: dict) -> bool:
"""是否采集端自建任务。source 缺失时按 id 前缀兜底(双保险)。"""
if not isinstance(task, dict):
return False
if task.get("source") == "local":
return True
return str(_tid(task) or "").startswith(LOCAL_TASK_PREFIX)
def new_local_task_id() -> str:
"""本机任务 id:local-YYYYmmdd-HHMMSS-<4hex>,与后端 UUID 不会相撞。"""
return "local-{}-{:04x}".format(
datetime.now().strftime("%Y%m%d-%H%M%S"), uuid.uuid4().int & 0xFFFF)
def validate_task_name(name: str, exclude_id: str | None = None
) -> tuple[bool, str]:
"""校验本机任务名,返回 (ok, 错误码)。错误码由 UI 本地化。
规则(保证 name == task_tag(name),即名字、录制目录、上传项目名一致):
* 去首尾空白后非空、≤ 64 字符;
* 只允许字母/数字/汉字/下划线/连字符(空格、点、斜杠等一律拒绝);
* Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)拒绝;
* 与任何现有任务重名(大小写不敏感、含 hidden 与种子任务)拒绝。
"""
with _lock:
tasks = _read_raw()
return _validate_name_locked(tasks, name, exclude_id)
def create_local_task(name: str, total_required: int, description: str = "",
created_by: str = "guest") -> dict:
"""新建本机任务并落盘,返回任务 dict。
失败抛 TaskValidationError(code 见 validate_task_name / total_invalid /
description_too_long)。任务仅创建者可见,进度只在本机累计。
"""
total = _validate_total(total_required)
desc = (description or "").strip()
if len(desc) > MAX_TASK_DESCRIPTION_LEN:
raise TaskValidationError("description_too_long")
with _lock:
tasks = _read_raw()
ok, code = _validate_name_locked(tasks, name)
if not ok:
raise TaskValidationError(code)
existing_ids = {_tid(t) for t in tasks}
task_id = new_local_task_id()
while task_id in existing_ids:
task_id = new_local_task_id()
task = {
"id": task_id,
"name": (name or "").strip(),
"description": desc,
"source": "local",
"created_by": created_by or "guest",
"status": "pending",
"total_required": total,
"assigned_at": datetime.now(timezone.utc).isoformat(),
"assigned_user": None,
# 进度分账三字段显式写 0:避开旧数据迁移回填(否则会去扫目录)
"local_count": 0,
"synced_count": 0,
"backend_count": 0,
"completed_count": 0,
}
tasks.append(task)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
task["status"] = _compute_status(task)
return dict(task)
def rename_local_task(task_id: str, new_name: str,
allow_with_data: bool = False) -> dict:
"""本机任务改名。只允许改本机任务,且默认拒绝「已有录制目录」的任务。
改名不迁移录制目录:本地已有数据时改名字会让旧目录失去归属、且可能
与上传队列里的路径脱节,因此默认拒绝(code="has_data");新名字的
目录已存在(孤儿目录)也拒绝(code="name_dir_exists")。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None:
raise TaskValidationError("task_not_found")
if not is_local_task(target):
raise TaskValidationError("not_local")
old_name = (target.get("name") or "").strip()
clean = (new_name or "").strip()
if clean == old_name:
target["status"] = _compute_status(target)
return dict(target)
ok, code = _validate_name_locked(tasks, clean, exclude_id=task_id)
if not ok:
raise TaskValidationError(code)
if not allow_with_data:
old_dir = _local_task_dir(old_name)
if old_dir and os.path.isdir(old_dir):
raise TaskValidationError("has_data")
new_dir = _local_task_dir(clean)
if new_dir and os.path.isdir(new_dir):
raise TaskValidationError("name_dir_exists")
target["name"] = clean
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def remove_local_task(task_id: str) -> bool:
"""硬移除本机任务(不写 hidden 墓碑)。
后端永远不会推送本机任务,不需要防复活;整条移除后同名任务可以重建。
只对 source=local 的条目生效,服务端任务返回 False(仍走 mark_hidden)。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None or not is_local_task(target):
return False
kept = [t for t in tasks if _tid(t) != task_id]
_write({"tasks": kept, "updated_at": datetime.now().isoformat()})
return True
def _local_visible(task: dict, identity: str | None) -> bool:
"""本机任务可见性:只有创建者身份可见;无 created_by 视为无主(所有人可见)。"""
creator = task.get("created_by")
if not creator:
return True
return creator == identity
def _local_task_dir(name: str) -> str:
"""本机任务录制目录(懒 import helpers,避免 task_record 顶层拉重依赖)。"""
if not name:
return ""
from core.helpers import task_dir_of
return task_dir_of(settings.RECORDING_DIR, name)
def _validate_name_locked(tasks: list[dict], name: str,
exclude_id: str | None = None) -> tuple[bool, str]:
"""名字校验(调用方持 _lock 并传入当前任务表)。"""
clean = (name or "").strip()
if not clean:
return False, "name_empty"
if len(clean) > MAX_TASK_NAME_LEN:
return False, "name_too_long"
if any(not (c.isalnum() or c in "_-") for c in clean):
return False, "name_illegal"
if clean.upper() in _WINDOWS_RESERVED_NAMES:
return False, "name_reserved"
key = clean.casefold()
for t in tasks:
if exclude_id and _tid(t) == exclude_id:
continue
if str(t.get("name") or "").casefold() == key:
return False, "name_duplicate"
return True, ""
def _validate_total(total_required) -> int:
"""目标段数:1 ~ MAX_TOTAL_REQUIRED 的整数(bool 不算)。"""
if isinstance(total_required, bool) or not isinstance(total_required, int):
raise TaskValidationError("total_invalid")
if total_required < 1 or total_required > MAX_TOTAL_REQUIRED:
raise TaskValidationError("total_invalid")
return total_required
def merge_backend_tasks(backend_tasks: list[dict], view_scope: str | None = None) -> list[dict]:
"""Merge backend tasks into local record. Returns merged list.
- Backend tasks update existing entries by id, new ones are added.
- Local-only tasks are preserved.
- Invalid/incomplete tasks are filtered out.
- 同名去重: 同一 (name, assigned_user) 组出现多个可见任务时,只保留
assigned_at 最新的一个(后端推送的优先胜出——"根据后台指令更新任务细节"),
其余标 hidden。公共任务(均无 assigned_user)分组结果与旧版等价。
- view_scope(新增): 当前会话身份。"guest"/None 不触发撤销清除;
用户名时,本地 assigned_user == 该用户名、且本轮后端未返回的任务
(已被后端撤销)从本地清除。游客轮询绝不触发撤销清除——防止把
他人指派任务误删。本机任务(source=local)无论身份一律豁免:它们
不在后端任务表里,按「后端没返回」清除会直接删掉自建任务。
"""
with _lock:
local = _read_raw()
backend_ids = {_tid(t) for t in backend_tasks}
# ── 阶段 0: 撤销清除(仅账号轮询;游客/无身份完全跳过)──
if view_scope not in (None, "guest"):
local = [
lt for lt in local
if is_local_task(lt)
or not (_assigned_user(lt) == view_scope and _tid(lt) not in backend_ids)
]
candidates: list[dict] = []
# ── 阶段 1: 后端任务(按 id 合并,逻辑与现状一致)──
for bt in backend_tasks:
if not _is_valid(bt):
continue
bid = _tid(bt)
local_match = _find(local, bid)
backend_global = max(0, int(bt.get("completed_count") or 0))
if local_match:
# 保留本地覆盖字段
for key in ("total_required", "params", "assigned_user"):
if key in local_match and key not in bt:
bt[key] = local_match[key]
# 进度分账:后端全局数权威;本机完成数/已同步水位沿用本地
_ensure_sync_fields(local_match)
bt["local_count"] = local_match["local_count"]
bt["synced_count"] = local_match["synced_count"]
bt["backend_count"] = backend_global
# 保留删除标记:用户删过的任务不能被后端推送复活
if local_match.get("hidden"):
bt["hidden"] = True
else:
# 新任务:本机 0 段全同步;backend_count 取后端全局数
# (保留 current_count 作新任务初值的旧语义)
bt["local_count"] = 0
bt["synced_count"] = 0
bt["backend_count"] = backend_global
candidates.append(bt)
# ── 阶段 2: 本地独有任务 ──
for lt in local:
if not _is_valid(lt):
continue
if _tid(lt) not in backend_ids:
candidates.append(dict(lt))
# ── 阶段 3: 同名去重((name, assigned_user) 墓碑)──
groups: dict[tuple, list[dict]] = {}
for c in candidates:
groups.setdefault((c["name"], _assigned_user(c)), []).append(c)
for group in groups.values():
visible = [g for g in group if not g.get("hidden")]
if len(visible) <= 1:
continue # 无碰撞(含整组已 hidden)→ 不动
backend_visible = [v for v in visible if _tid(v) in backend_ids]
pool = backend_visible if backend_visible else visible
winner = max(pool, key=_assigned_at_key)
for g in visible:
if g is not winner:
g["hidden"] = True # 持久化墓碑,防止复活
for t in candidates:
_ensure_sync_fields(t)
_recount(t)
t["status"] = _compute_status(t)
save_tasks(candidates)
return load_tasks()
def mark_hidden(task_id: str):
"""Mark a task as hidden so it no longer appears in the UI."""
with _lock:
tasks = _read_raw()
for t in tasks:
if _tid(t) == task_id:
t["hidden"] = True
break
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
def refresh_progress() -> list[dict]:
"""Reload task list with persisted completion counts."""
return load_tasks()
def increment_task_completed(task_name: str, task_id: str | None = None,
assigned_user: str | None = None) -> dict | None:
"""一次录制完成:把对应任务的录制完成次数持久化 +1,返回更新后的任务。
匹配优先级:
1) task_id 提供时按 id 精确匹配(须 name 一致)
2) (name, assigned_user) 精确匹配(assigned_user 为 None 时匹配公共任务)
3) 同名条目唯一时回退唯一匹配(兼容旧数据/手动临时任务名)
4) 多名同名且身份不匹配 → 返回 None 不计数(跨身份串扰防护)
task_id/assigned_user 由调用方从所选任务(_current_task)提供,
而非会话身份——避免"登录用户录制公共任务却按用户名匹配"的错配。
任务不在 tasks.json(手动临时任务名)时不计数,返回 None。
会话目录随后被删除也不影响该计数——进度以持久化值为准。
"""
if not task_name:
return None
with _lock:
tasks = _read_raw()
target = None
if task_id:
for t in tasks:
if _tid(t) == task_id and t.get("name") == task_name:
target = t
break
if target is None:
for t in tasks:
if (t.get("name") == task_name
and _assigned_user(t) == (assigned_user or None)):
target = t
break
if target is None:
matches = [t for t in tasks if t.get("name") == task_name]
if len(matches) == 1:
target = matches[0]
if target is None:
return None
_ensure_sync_fields(target)
target["local_count"] = int(target.get("local_count", 0) or 0) + 1
_recount(target)
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def pending_sync_tasks() -> list[dict]:
"""所有 local_count > synced_count 的任务快照(含 hidden,不按身份过滤——
身份不符由服务端 401/403 处理,跳过本轮即可)。
纯读路径:直接 _read_raw(),跳过未迁移记录(无 local_count),
绝不触发迁移写盘(flush 只上报,不改变本地计数语义)。
本机任务(source=local)显式跳过:后端没有对应任务,上报必被拒 400。
"""
with _lock:
tasks = _read_raw()
out = []
for t in tasks:
if is_local_task(t):
continue
local = t.get("local_count")
if not isinstance(local, int):
continue
synced = int(t.get("synced_count", 0) or 0)
tid = _tid(t)
if local > synced and tid:
out.append({"id": tid, "name": t.get("name", ""),
"local_count": local, "synced_count": synced})
return out
def mark_synced(task_id: str, synced_count: int, backend_count: int) -> dict | None:
"""flush 成功后回写同步状态。
synced_count 必须是「快照水位 + 本次增量」(不是当前 local_count——
快照与响应之间 UI 线程可能又录了一段);水位只前进不后退;
backend_count 用响应值无条件替换(服务端权威,迁移基线可能高于真实
全局数,不能 max-前向)。返回更新后的任务。
"""
with _lock:
tasks = _read_raw()
target = _find(tasks, task_id)
if target is None or not isinstance(target.get("local_count"), int):
return None
if synced_count > (int(target.get("synced_count", 0) or 0)):
target["synced_count"] = synced_count
target["backend_count"] = max(0, int(backend_count or 0))
_recount(target)
target["status"] = _compute_status(target)
_write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
return dict(target)
def task_by_id(task_id: str) -> dict | None:
tasks = load_tasks()
return _find(tasks, task_id)
# ═══════════════════════════════════════════════════════════
# Internal
# ═══════════════════════════════════════════════════════════
def _read_raw() -> list[dict]:
if not os.path.isfile(_TASKS_FILE):
_init_seed()
try:
with open(_TASKS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("tasks", [])
except (json.JSONDecodeError, OSError):
_init_seed()
return [dict(t) for t in _SEED_TASKS]
def _write(data: dict):
os.makedirs(os.path.dirname(_TASKS_FILE), exist_ok=True)
tmp = _TASKS_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, _TASKS_FILE)
def _init_seed():
_write({"tasks": [dict(t) for t in _SEED_TASKS], "updated_at": datetime.now().isoformat()})
def _tid(task: dict) -> str:
return task.get("id", task.get("task_id", ""))
def _assigned_user(task: dict):
"""任务的指派用户;null/空串/缺失统一归一化为 None(= 公共任务)。"""
return task.get("assigned_user") or None
def _assigned_at_key(task: dict) -> tuple:
"""排序键: 带有效时间的排前,null/空/解析失败排后。
(0, raw) 与 (1, ts) 首元素不同,跨类不会比较第二元素。"""
raw = task.get("assigned_at") or ""
if not raw:
return (0, "")
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return (1, dt.timestamp())
except ValueError:
return (0, raw) # 解析失败: 排最后,按原始字符串保序
def _find(tasks: list[dict], task_id: str) -> dict | None:
for t in tasks:
if _tid(t) == task_id:
return t
return None
def _is_valid(task: dict) -> bool:
name = task.get("name", task.get("task_name", ""))
tid = task.get("id", task.get("task_id", ""))
return bool(name and name.strip()) and bool(tid and tid.strip()) and task.get("total_required", 0) > 0
def _compute_status(task: dict) -> str:
total = task.get("total_required", 0)
completed = task.get("completed_count", 0)
if total <= 0:
return "pending"
if completed >= total:
return "completed"
if completed > 0:
return "in_progress"
return "pending"
def _count_with_fallback(task: dict) -> int:
"""任务的录制完成次数:优先持久化值,缺字段(旧版本数据)回退目录扫描。"""
cur = task.get("completed_count")
if isinstance(cur, int) and cur >= 0:
return cur
return _count_sessions(task.get("name", ""))
def _count_sessions(task_name: str) -> int:
"""扫描录制目录统计 episode 数 —— 仅用于旧数据迁移回填初值,非进度权威来源。
v1.1.0 池化布局:list_task_episodes(videos/data 文件组扫描,与
episodes 每段文件一致——删除时两者同步移除);旧格式任务目录
回退按会话子目录计数。
"""
if not task_name:
return 0
task_dir = os.path.join(settings.RECORDING_DIR, task_name)
if not os.path.isdir(task_dir):
return 0
try:
from core.helpers import detect_session_format, list_task_episodes
if detect_session_format(task_dir) == "pooled":
return len(list_task_episodes(task_dir))
except ImportError:
pass
count = 0
try:
for entry in os.listdir(task_dir):
ses_dir = os.path.join(task_dir, entry)
if not os.path.isdir(ses_dir):
continue
if os.path.isfile(os.path.join(ses_dir, "meta", "info.json")):
count += 1
except OSError:
pass
return count
def _ensure_sync_fields(task: dict) -> bool:
"""补全 local/synced/backend 三字段(迁移);返回是否发生迁移(需写回)。
迁移(幂等,字段存在且为合法 int 即跳过):
local_count = 旧 completed_count(目录扫描兜底)
synced_count = local_count(历史不补报,避免重复计数)
backend_count = local_count(历史基线保显示连续;首次 merge/flush
用真实全局数无条件替换)
"""
changed = False
local = task.get("local_count")
if not isinstance(local, int) or local < 0:
task["local_count"] = _count_with_fallback(task)
changed = True
synced = task.get("synced_count")
if not isinstance(synced, int) or synced < 0:
task["synced_count"] = task["local_count"]
changed = True
backend = task.get("backend_count")
if not isinstance(backend, int) or backend < 0:
task["backend_count"] = task["local_count"]
changed = True
return changed
def _recount(task: dict) -> int:
"""显示值 = backend_count + (local_count - synced_count),写回 completed_count。
防御:synced 不超 local(异常水位按已同步处理,宁可少显示不可多显示)。
"""
local = int(task.get("local_count", 0) or 0)
synced = min(int(task.get("synced_count", 0) or 0), local)
task["completed_count"] = int(task.get("backend_count", 0) or 0) + (local - synced)
return task["completed_count"]
|