12 KiB
Archive Upload + Copy-Path Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Checkbox steps.
Goal: Let user copy archive path as Windows/Linux path, and upload videos + create date folders from the Video Archive page.
Architecture: Two new library endpoints stream files into the archive (compose mount flips :ro → rw); project payload gains video_root_linux/video_root_windows derived from env; one new component ArchiveControls.jsx holds copy buttons + folder/upload UI; docs REQ-010/REQ-163 revised (user approved).
Tech Stack: FastAPI + library.py path helpers, React 19, nginx proxy, Docker Compose.
Spec: docs/requirements.md (REQ-010, REQ-163 revised; REQ-171, REQ-172 new), docs/design.md API table.
Global Constraints
- Commit only files named in your task brief; never
git add -A(the tree carries unrelated dirty files) - Archive paths only via
library._safe_join— no traversal (backend/library.py:19) - Date folders:
^\d{4}-\d{2}-\d{2}$; extensions:video.VIDEO_EXTS = (".mp4",".mkv",".mov",".avi",".webm",".m4v")(backend/video.py:15) - No delete/rename UI (user decision)
- New frontend file ≤400 lines; existing
LibraryPage.jsxgets small diff only - UI checklist: no emoji,
cursor:pointer, hover 150-300ms, focus visible, contrast ≥4.5:1,prefers-reduced-motion, responsive - Page copy is Indonesian-dominant → new button labels Indonesian
- Verification = curl + browser (repo has no test framework)
Task 1: Docs — REQ revisions + new REQs
Files:
- Modify:
docs/requirements.md - Modify:
docs/design.md(API table ~line 402-405 area, add rows) - Modify:
docs/tasks.md(add 3 TODO entries)
Steps:
- Revise REQ-010 → text: "The video archive lives at a local path (disk or mount). Videos may be copied in from disk or uploaded through the browser into an existing date folder (REQ-171)."
- Revise REQ-163 → "Nothing existing in the archive is moved, renamed or deleted; only user-initiated uploads and folder creation append to it."
- Add REQ-171 — "The Video Archive page can create a
YYYY-MM-DDfolder and upload videos into it. Uploads are streamed to disk, never overwrite an existing file, and only acceptvideo.VIDEO_EXTS." - Add REQ-172 — "The Video Archive page shows a button that copies the archive's host path to the clipboard, as a Linux path and as a Windows (
\\wsl.localhost\<distro>\...) path when known." - Add design.md API rows:
POST /api/projects/{id}/library/dates?date=,POST /api/projects/{id}/library/upload?date=(multipartfile), project payload +video_root_linux,video_root_windows - Add tasks.md entries with below verification criteria, status
[TODO]
Task 2: Backend — host paths in project payload + env passthrough
Files:
- Modify:
backend/config.py(add function afterVIDEO_ROOT, line 23) - Modify:
backend/projects.py:180(serializer dict) - Modify:
docker-compose.yml(backendenvironment:) - Modify:
.env(absoluteVIDEO_ARCHIVE_HOST, addWSL_DISTRO_NAME),.env.example(same + comment)
Interfaces:
- Produces:
config.archive_host_paths() -> {"linux": str|None, "windows": str|None}; payload keysvideo_root_linux,video_root_windows
Steps:
- Add to
backend/config.py:
def archive_host_paths() -> dict:
"""Where the archive sits on the host — for the copy-path buttons (REQ-172).
ponytail: relative VIDEO_ARCHIVE_HOST (./data/archive) cannot be resolved
inside the container; set it absolute in .env or the buttons stay hidden."""
import re
host = os.environ.get("VIDEO_ARCHIVE_HOST", "")
if os.path.isabs(host):
linux = host.rstrip("/")
elif VIDEO_ROOT != "/videos": # bare-metal: VIDEO_ROOT is already the host path
linux = VIDEO_ROOT
else:
return {"linux": None, "windows": None}
if re.match(r"^[A-Za-z]:[\\/]", linux):
return {"linux": None, "windows": linux.replace("/", "\\")}
distro = os.environ.get("WSL_DISTRO_NAME", "")
windows = f"\\\\wsl.localhost\\{distro}\\" + linux.lstrip("/") if distro else None
return {"linux": linux, "windows": windows}
- In project serializer dict (
backend/projects.py:180) add:
"video_root_linux": config.archive_host_paths()["linux"],
"video_root_windows": config.archive_host_paths()["windows"],
docker-compose.ymlbackend environment add:
VIDEO_ARCHIVE_HOST: ${VIDEO_ARCHIVE_HOST:-./data/archive}
WSL_DISTRO_NAME: ${WSL_DISTRO_NAME:-}
.env:VIDEO_ARCHIVE_HOST=/home/araaraenjoyer/dbs_project/reTraining/data/archive, addWSL_DISTRO_NAME=Ubuntu; mirror in.env.examplewith comment "must be absolute for the copy-path buttons (REQ-172)"- Verify: rebuild backend,
curl -s localhost:9010/api/projects/1→ keys present, windows value\\wsl.localhost\Ubuntu\home\...
Task 3: Backend — mkdir + upload endpoints
Files:
- Modify:
backend/library.py(afterlist_videos, ~line 99) - Modify:
backend/api/projects.py(after line 151)
Interfaces:
- Consumes:
_safe_join,_effective_root,video.VIDEO_EXTS - Produces:
library.LibraryConflict(LibraryError);library.create_date(video_root, date) -> str;library.upload_video(video_root, date, filename, chunks) -> str(returnsrel); endpointsPOST /library/dates→{"date"}(409 if exists),POST /library/upload→{"rel"}(409 exists, 400 bad ext/date/name)
Steps:
- Add to
backend/library.py:
class LibraryConflict(LibraryError):
pass
def create_date(video_root: str, date: str) -> str:
"""Make a YYYY-MM-DD folder; never touch an existing one (REQ-171)."""
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
raise LibraryError("Folder name must be YYYY-MM-DD")
folder = _safe_join(_effective_root(video_root), date)
if os.path.exists(folder):
raise LibraryConflict(f"Folder already exists: {date}")
os.makedirs(folder)
return folder
def upload_video(video_root: str, date: str, filename: str, chunks) -> str:
"""Stream `chunks` into <date>/<filename>; atomic via .part + rename."""
name = os.path.basename(filename or "")
if not name or name != filename or not name.lower().endswith(video.VIDEO_EXTS):
raise LibraryError(f"Unsupported file: {filename}")
root = _effective_root(video_root)
if not os.path.isdir(_safe_join(root, date)):
raise LibraryError(f"No such date in the archive: {date}")
dest = _safe_join(root, date, name)
if os.path.exists(dest):
raise LibraryConflict(f"Already exists: {date}/{name}")
part = dest + ".part"
try:
with open(part, "wb") as handle:
for chunk in chunks:
handle.write(chunk)
os.replace(part, dest)
except BaseException:
if os.path.exists(part):
os.unlink(part)
raise
return f"{date}/{name}"
- Add endpoints to
backend/api/projects.py(File,UploadFilealready imported line 8):
@router.post("/{project_id}/library/dates")
def create_library_date(project_id: int, date: str) -> dict:
project = project_or_404(project_id)
try:
library.create_date(project["video_root"], date)
except library.LibraryConflict as exc:
raise HTTPException(409, str(exc))
except library.LibraryError as exc:
raise HTTPException(400, str(exc))
return {"date": date}
@router.post("/{project_id}/library/upload")
def upload_library_video(project_id: int, date: str,
file: UploadFile = File(...)) -> dict:
project = project_or_404(project_id)
try:
rel = library.upload_video(
project["video_root"], date, file.filename or "",
iter(lambda: file.file.read(1 << 20), b""))
except library.LibraryConflict as exc:
raise HTTPException(409, str(exc))
except library.LibraryError as exc:
raise HTTPException(400, str(exc))
return {"rel": rel}
- Verify (deferred to integration phase — needs Docker rebuild after Tasks 2-4): mkdir 200/409/400, upload 200/409/400, list shows file,
.partcleaned, shellrm -rftest folder afterwards
Task 4: Infra — rw mount + nginx body size
Files:
- Modify:
docker-compose.yml(archive volume line...:/videos:ro→ drop:ro) - Modify:
frontend/nginx.conf:7(client_max_body_size 512m→20g, update comment)
Steps:
- Edit both; build deferred to integration phase
- Verify (integration):
docker inspect retraining-backend-1 --format '{{json .Mounts}}'shows rw (no"RO":true);curl -s localhost:9010/api/health→ 200
Task 5: Frontend — api helpers + ArchiveControls component + LibraryPage wiring
Files:
- Modify:
frontend/src/api.js(after line 65) - Create:
frontend/src/components/ArchiveControls.jsx - Modify:
frontend/src/pages/LibraryPage.jsx(~line 474-498 header, ~line 399 load effect)
Interfaces:
- Consumes: payload keys
video_root_linux,video_root_windows; endpoints Task 3 - Produces:
api.createLibraryDate(id, date),api.uploadLibraryVideo(id, date, file, onProgress); component<ArchiveControls project={project} projectId={projectId} onChanged={reloadArchive} />
Steps:
frontend/src/api.js:
createLibraryDate: (id, date) =>
request(`/projects/${id}/library/dates?date=${encodeURIComponent(date)}`, { method: 'POST' }),
uploadLibraryVideo: (id, date, file, onProgress) =>
new Promise((resolve, reject) => {
const form = new FormData()
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/projects/${id}/library/upload?date=${encodeURIComponent(date)}`)
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) }
xhr.onload = () => {
let payload = {}
try { payload = JSON.parse(xhr.responseText || '{}') } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(payload)
else reject(new Error(payload.detail || `${xhr.status} ${xhr.statusText}`))
}
xhr.onerror = () => reject(new Error('network error'))
xhr.send(form)
}),
- Create
frontend/src/components/ArchiveControls.jsx(~170 lines) with:CopyPath: twobtn btn-ghostbuttons rendered only whenproject.video_root_linux/_windowstruthy;navigator.clipboard.writeText→ label swap "Tersalin" 1.5s (aria-live="polite");title=shows full pathNew folder:<input type="date">+ button →api.createLibraryDate→onChanged(); conflict (409) → error textUpload: hidden<input type="file" multiple accept=".mp4,.mkv,.mov,.avi,.webm,.m4v">; button "Unggah video"; sequentialapi.uploadLibraryVideoper file with thin progress bar (width % via inline style; CSS transition 200ms, disabled underprefers-reduced-motion); per-file error rows;onChanged()after each success- All:
cursor:pointer, hover opacity/transition 200ms, visible:focus-visibleoutline (match existingbtnstyles), no emoji, 4.5:1 text
LibraryPage.jsxwiring:- add
const reloadArchive = useCallback(() => { api.archiveCycles(projectId).then(l => { setDates(l.cycles); if (!l.cycles.some(c => c.cycle === selected)) setSelected(l.cycles[0]?.cycle ?? null) }).catch(() => {}) }, [projectId, selected]) - render
<ArchiveControls project={project} projectId={projectId} onChanged={reloadArchive} />inside page-head right column above the truck-scan button (line ~484), stack vertically withgap: 8
- add
- Verify (integration phase): build frontend, browser checks + AGENTS §8 checklist
Task 6: Docs close-out
Steps:
- Flip new
docs/tasks.mdentries to[DONE]with verification evidence - Re-read
docs/design.mdAPI table + disk layout — matches shipped behavior (no doc lies)