mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 17:01:38 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2bb178d5 | ||
|
|
b1f1880736 | ||
|
|
338726d581 | ||
|
|
32727fc6e5 |
No files matched your search
+5
-5
@@ -18,11 +18,6 @@
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
@@ -224,3 +219,8 @@
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Steam clip export collision fixtures, 25 October 2026
|
||||
|
||||
Base: `d79efb4` (Frame Control v0.4.30). Issue:
|
||||
[#212](https://github.com/saphid/frame-control/issues/212).
|
||||
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
|
||||
|
||||
## Baseline reproduction
|
||||
|
||||
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
|
||||
Python module and exported three fake sources into a temporary directory:
|
||||
|
||||
```
|
||||
1/clips/clip_620/video/bg
|
||||
2/clips/clip_620/video/bg
|
||||
1/clips/clip_620/video/other
|
||||
```
|
||||
|
||||
Each source's payload was its own ID. All three exports returned the same
|
||||
destination, `clip_620.mp4`; listing marked all three saved. The final file
|
||||
contained only the last source ID. No SSH or headset was used.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
|
||||
equal clip names across accounts, nested sessions with equal leaf names,
|
||||
different leaf names under one clip and the background/clip distinction.
|
||||
All ten payloads survive at distinct destinations. Listing marks only the
|
||||
identities already exported. A pre-existing flat MP4 retains its original
|
||||
bytes and does not mark any identity saved. Normal export still returns
|
||||
`message` and `file`, removes its remote temporary export and clears local
|
||||
staging; a failed re-export preserves the completed file.
|
||||
|
||||
`tests/test_clip_copy.py` exercises the server's list and manual export
|
||||
functions and its actual auto-sync callbacks with fake transport. A manual
|
||||
copy is recognised by auto-sync, all other account/session identities copy,
|
||||
and switching to a second headset copies its own sources without overwriting
|
||||
the first. A delayed manual job refuses a changed headset with status 409.
|
||||
Both local phone mode and local headset runtime reject Steam clip list,
|
||||
export and auto-sync requests with status 400. The separate `Recorder` path
|
||||
is unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
|
||||
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
|
||||
export of a generated 65-second DASH fixture and recording stop/cleanup.
|
||||
- `PYTHONPATH=tests python3 -m unittest test_server -v`
|
||||
— 44 tests passed in 12.315 seconds, no skips.
|
||||
- `node --test site/test/*.test.mjs` — 2 tests passed.
|
||||
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
|
||||
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
|
||||
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
|
||||
and `git diff --check` — passed.
|
||||
|
||||
The server and full Python suites require localhost sockets and were run
|
||||
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
|
||||
data, SSH settings, home directories and service URLs.
|
||||
|
||||
## Limits and upgrade behaviour
|
||||
|
||||
No real headset was accessed: the shared device lock is held by other work.
|
||||
This is Linux fixture and server coverage, not a fresh packaged-app or
|
||||
Windows/macOS verification. Existing real-headset export evidence in the
|
||||
recording guide predates this destination change.
|
||||
|
||||
Old flat exports cannot prove headset/account/session identity and are left
|
||||
untouched. Previously remembered auto-sync IDs retain their existing
|
||||
watermark/history behaviour; **Copy everything now** or manual **Export**
|
||||
creates an identified copy. No release, version bump, merge or social post
|
||||
is included. PR #113 is outside this task.
|
||||
+15
-29
@@ -69,31 +69,11 @@ yes-or-no answer about passthrough pixels. See the
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
|
||||
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
Each export keeps its headset, Steam account and complete recording session ID:
|
||||
|
||||
```
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
|
||||
```
|
||||
|
||||
The headset folder uses the same short device-identity hash as screenshots.
|
||||
All folders in the Steam clip ID are kept, including nested sessions, so equal
|
||||
clip names across accounts, headsets or sessions keep separate files. Manual
|
||||
**Export** and automatic copying use the same path and saved marker. Exporting
|
||||
the same identified session again replaces only that session's export.
|
||||
|
||||
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
|
||||
untouched. Their source identity cannot be proved, so they do not mark a clip
|
||||
as saved. Export again or use **Copy everything now** to make an identified
|
||||
copy. Automatic copying still remembers IDs copied before the upgrade; use
|
||||
**Copy everything now** to copy those again. This destination change has local
|
||||
fixture coverage; it has not been tested on a real headset.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
@@ -157,20 +137,27 @@ case was tested with separate fake sources, not two real same-name screenshots.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
- **When one file keeps failing** it tries at most three times, then leaves
|
||||
the problem visible in Settings. Fix the source problem and press **Resume**
|
||||
to allow another three attempts without restarting Frame Control. **Copy
|
||||
everything now** also resets failed attempts and includes older files.
|
||||
Resume keeps the history of successful copies, so it does not recopy them.
|
||||
- **With several headsets** each check and copy belongs to the headset selected
|
||||
when the check began. Switching between lists or copies stops that pass;
|
||||
another headset's saved files cannot become part of its copy history.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
Retry and switch handling have offline regression coverage with fake sources;
|
||||
see the [test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame, device_id=...)` and
|
||||
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
|
||||
side; the server passes how to reach the Frame and its pinned device identity.
|
||||
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
@@ -193,8 +180,7 @@ The API, with the same guards as every other request:
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, `tests/test_clip_copy.py` for server destinations
|
||||
and collision coverage, and `tests/test_autosync.py` for automatic
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
|
||||
@@ -12,6 +12,21 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
Automatic screenshot and clip copying has offline coverage in
|
||||
`tests/test_autosync.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_autosync.py -v
|
||||
```
|
||||
|
||||
Fake sources exercise the three-attempt limit, visible exhausted errors,
|
||||
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
|
||||
pauses and headset changes around lists and transfers. Server tests use the
|
||||
production `working(meant)` reservation and listing/copy adapters; a Node test
|
||||
executes the Settings renderer to check that Resume is available after an error.
|
||||
These tests use no headset. See the
|
||||
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
|
||||
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
|
||||
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
|
||||
|
||||
+227
-2
@@ -7,11 +7,13 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
@@ -35,13 +37,15 @@ class FakeFrame:
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self):
|
||||
def list_shots(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_shots")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"shots": list(self.shots)}
|
||||
|
||||
def list_clips(self):
|
||||
def list_clips(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
@@ -263,6 +267,134 @@ class AutoSyncTest(unittest.TestCase):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
|
||||
self.assertIn("Resume", self.sync.state()["error"] or "")
|
||||
|
||||
def test_explicit_retry_recovers_without_recopying_successful_items(self):
|
||||
for kind in ("shots", "clips"):
|
||||
for action in ("resume", "copy_all"):
|
||||
with self.subTest(kind=kind, action=action):
|
||||
self.frame = FakeFrame()
|
||||
f = self.frame
|
||||
sync = self.make()
|
||||
sync.update({"screenshots": True, "clips": True})
|
||||
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
|
||||
error_attr = "save_error" if kind == "shots" else "export_error"
|
||||
copy_call = "save_shots" if kind == "shots" else "export_clip"
|
||||
setattr(f, error_attr, RuntimeError("source not ready"))
|
||||
for _ in range(fa.TRIES + 2):
|
||||
sync.tick()
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
self.assertIn("source not ready", sync.state()["error"] or "")
|
||||
setattr(f, error_attr, None)
|
||||
sync.tick() # recovery alone must not create unlimited automatic retries
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
getattr(sync, action)()
|
||||
sync.tick()
|
||||
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
|
||||
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertIsNone(sync.state()["error"])
|
||||
# Resume preserves the per-headset ledger even if a local file was deleted.
|
||||
for item in f.shots + f.clips:
|
||||
item["saved"] = False
|
||||
sync.resume()
|
||||
sync.tick()
|
||||
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
|
||||
self.sync.path.unlink(missing_ok=True)
|
||||
|
||||
def test_explicit_retry_is_still_bounded(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for action in ("resume", "copy_all"):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
before = self.frame.calls.count("export_clip")
|
||||
getattr(self.sync, action)()
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
|
||||
self.assertIn("bad source", self.sync.state()["error"] or "")
|
||||
|
||||
def test_failed_item_saved_by_hand_clears_error(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for _ in range(fa.TRIES + 1):
|
||||
self.sync.tick()
|
||||
self.frame.clips[0]["saved"] = True
|
||||
self.sync.tick()
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
|
||||
|
||||
def test_switch_after_capture_does_not_list_the_new_headset(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
def switched():
|
||||
self.frame.device = "frame-b"
|
||||
return False
|
||||
self.sync.busy = switched # tick has already captured frame-a
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_list_callbacks_receive_the_captured_device(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.sync.list_shots = mock.Mock(return_value={"shots": []})
|
||||
self.sync.list_clips = mock.Mock(return_value={"clips": []})
|
||||
self.sync.tick()
|
||||
self.sync.list_shots.assert_called_once_with("frame-a")
|
||||
self.sync.list_clips.assert_called_once_with("frame-a")
|
||||
|
||||
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
def listing(*_args):
|
||||
self.frame.device = "frame-b"
|
||||
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
|
||||
setattr(self.sync, "list_" + kind, listing)
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(self.sync._synced("frame-b", kind), set())
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5, saved=True)]
|
||||
save = self.sync.save_shots
|
||||
def then_switch(device, ids):
|
||||
save(device, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync.save_shots = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
|
||||
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
|
||||
|
||||
def test_switch_after_bookkeeping_stops_before_next_kind(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
mark = self.sync._mark
|
||||
def then_switch(device, kind, ids):
|
||||
mark(device, kind, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync._mark = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
|
||||
def test_switch_before_transfer_does_not_copy(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
setattr(self.frame, kind, [item])
|
||||
def switched_space(_folder):
|
||||
self.frame.device = "frame-b"
|
||||
return 100 * GB
|
||||
self.sync.space = switched_space
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
@@ -275,6 +407,75 @@ class AutoSyncTest(unittest.TestCase):
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
for kind in ("shots", "clips"):
|
||||
with self.subTest(kind=kind):
|
||||
active = {"id": "frame-a"}
|
||||
link = mock.Mock()
|
||||
link.active_device.side_effect = lambda: dict(active)
|
||||
listing = mock.Mock(return_value={kind: [
|
||||
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
|
||||
def switch_then_list(device):
|
||||
active["id"] = "frame-b"
|
||||
return server._sync_list(listing)(device)
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
|
||||
lambda: active["id"], lambda: False,
|
||||
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
|
||||
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
sync.tick()
|
||||
listing.assert_not_called()
|
||||
sync.save_shots.assert_not_called()
|
||||
sync.export_clip.assert_not_called()
|
||||
self.assertEqual(sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(sync._synced("frame-b", kind), set())
|
||||
self.assertIn("switched headsets", sync.state()["error"])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_rejects_switch_before_reserving_work(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
server._sync_list(listing)("frame-a")
|
||||
listing.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_reserves_work_on_the_captured_device(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
|
||||
def listing():
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({"action": "use", "id": "frame-b"})
|
||||
return {"shots": []}
|
||||
with mock.patch.object(server, "LINK", link), \
|
||||
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
|
||||
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_copy_rejects_switch_after_listing(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a"}
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
|
||||
mock.patch.object(server, "save_shots") as save, \
|
||||
mock.patch.object(server.frame_clips, "export_clip") as export:
|
||||
server._sync_list(lambda: {"shots": []})("frame-a")
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
|
||||
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
copy()
|
||||
save.assert_not_called()
|
||||
export.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
@@ -287,5 +488,29 @@ class Server(unittest.TestCase):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
|
||||
class Settings(unittest.TestCase):
|
||||
def test_resume_is_available_after_a_copy_error(self):
|
||||
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
|
||||
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
|
||||
harness = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const nodes = {};
|
||||
const $ = id => nodes[id] ||= {};
|
||||
const HOST = {mobile: false};
|
||||
const esc = value => value;
|
||||
'''
|
||||
checks = r'''
|
||||
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
|
||||
renderAutoSync(state);
|
||||
assert.equal($('syncResume').hidden, !(state.error || state.paused));
|
||||
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Clip destinations through the server and automatic copying, without a headset."""
|
||||
import sandbox # noqa: F401
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync
|
||||
import frame_clips
|
||||
import server
|
||||
|
||||
|
||||
IDS = ["1/clips/clip_620/video/bg", "2/clips/clip_620/video/bg",
|
||||
"1/clips/clip_620/video/other", "1/clips/other/video/bg"]
|
||||
NOW = 1800000000
|
||||
|
||||
|
||||
class ClipCopyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = Path(tempfile.mkdtemp(prefix="frame-clip-copy-"))
|
||||
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
|
||||
self.device = "frame-a"
|
||||
self.link = mock.Mock()
|
||||
self.link.active_device.side_effect = lambda: {"id": self.device, "name": self.device}
|
||||
self.link.snapshot.return_value = {"phase": "connected"}
|
||||
self.exports = []
|
||||
self.payload = None
|
||||
for name, value in (("LINK", self.link), ("LOCAL", False)):
|
||||
patch = mock.patch.object(server, name, value)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
patch = mock.patch.object(frame_clips, "VIDEOS_DIR", self.folder)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
for name, fn in (("_clips_frame", self.frame), ("fetch_file", self.fetch),
|
||||
("ssh", lambda *a, **k: "")):
|
||||
patch = mock.patch.object(server, name, side_effect=fn)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
(self.folder / "clip_620.mp4").write_bytes(b"legacy export")
|
||||
|
||||
def frame(self, args, timeout):
|
||||
if args == ["list"]:
|
||||
return {"clips": [{"id": cid, "kind": "clip", "time": NOW + 5, "idle": 60} for cid in IDS]}
|
||||
self.exports.append((self.device, args[1]))
|
||||
self.payload = f"{self.device}: {args[1]}".encode()
|
||||
return {"path": f"{frame_clips.WORK}/export-0123456789abcdef/clip_620.mp4",
|
||||
"size": len(self.payload)}
|
||||
|
||||
def fetch(self, remote, target):
|
||||
target.write_bytes(self.payload)
|
||||
|
||||
def test_manual_export_lists_only_matching_identity_and_keeps_job_shape(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()) as job:
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.device = device
|
||||
self.assertFalse(any(c["saved"] for c in server.list_clips()["clips"]))
|
||||
result = server.export_clip({"id": IDS[0]})
|
||||
self.assertEqual(set(result), {"message", "file"})
|
||||
self.assertEqual(Path(result["file"]).read_bytes(), f"{device}: {IDS[0]}".encode())
|
||||
self.assertEqual([c["saved"] for c in server.list_clips()["clips"]], [True, False, False, False])
|
||||
self.assertEqual(job.call_args.args[0], "Export clip_620.mp4")
|
||||
first = frame_clips.clip_local_path(IDS[0], "frame-a", self.folder)
|
||||
self.assertEqual(first.read_bytes(), f"frame-a: {IDS[0]}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_auto_sync_uses_same_identity_as_manual_exports(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()):
|
||||
server.export_clip({"id": IDS[0]})
|
||||
sync = frame_autosync.AutoSync(
|
||||
server._sync_list(server.list_shots), server._sync_shots,
|
||||
server._sync_list(server.list_clips), server._sync_clip,
|
||||
server._sync_device, lambda: False, shots_folder=self.folder / "shots",
|
||||
clips_folder=self.folder, path=self.folder / "auto-sync.json", clock=lambda: NOW,
|
||||
space=lambda folder: 100 * 1024**3)
|
||||
sync.update({"clips": True})
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [("frame-a", cid) for cid in IDS])
|
||||
self.device = "frame-b"
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [(device, cid) for device in ("frame-a", "frame-b") for cid in IDS])
|
||||
sync.tick()
|
||||
self.assertEqual(len(self.exports), 8)
|
||||
for device in ("frame-a", "frame-b"):
|
||||
for cid in IDS:
|
||||
target = frame_clips.clip_local_path(cid, device, self.folder)
|
||||
self.assertEqual(target.read_bytes(), f"{device}: {cid}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_delayed_job_refuses_a_different_headset(self):
|
||||
with mock.patch.object(server, "start_job", return_value={"job": "queued"}) as job:
|
||||
self.assertEqual(server.export_clip({"id": IDS[0]}), {"job": "queued"})
|
||||
self.device = "frame-b"
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
job.call_args.args[1]()
|
||||
self.assertEqual(error.exception.status, 409)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
def test_local_and_headset_modes_reject_steam_clip_exports(self):
|
||||
for headset in (False, True):
|
||||
with self.subTest(headset=headset), mock.patch.object(server, "LOCAL", True), \
|
||||
mock.patch.object(server, "HEADSET_RUNTIME", headset):
|
||||
for action in (server.list_clips, lambda: server.export_clip({"id": IDS[0]}),
|
||||
lambda: server.auto_sync({"action": "now"})):
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
action()
|
||||
self.assertEqual(error.exception.status, 400)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+8
-65
@@ -71,17 +71,6 @@ class Ids(unittest.TestCase):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
clips.remove_command(bad)
|
||||
|
||||
def test_local_path_keeps_validated_identity_and_hashes_device_names(self):
|
||||
folder = Path("exports")
|
||||
target = clips.clip_local_path("123/clips/clip/video/session", "../../device:name", folder)
|
||||
self.assertRegex(target.parts[1], r"^headset-[0-9a-f]{12}$")
|
||||
self.assertEqual(target.parts[2:], ("123", "clips", "clip", "video", "session", "clip.mp4"))
|
||||
for bad in (None, "", 42):
|
||||
with self.subTest(device=bad), self.assertRaises(ValueError):
|
||||
clips.clip_local_path("123/video/bg", bad, folder)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.clip_local_path("../clips/clip", "frame-a", folder)
|
||||
|
||||
def test_recording_names_checked(self):
|
||||
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
|
||||
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
|
||||
@@ -167,10 +156,10 @@ class Sessions(unittest.TestCase):
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
|
||||
{"id": "1/video/bg_620_20261020_201950"}]}
|
||||
got = clips.list_clips(frame, folder=here, device_id="frame-a")
|
||||
got = clips.list_clips(frame, folder=here)
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
@@ -272,16 +261,12 @@ class ComputerSide(unittest.TestCase):
|
||||
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
|
||||
run = lambda cmd, timeout: calls.append(("run", cmd))
|
||||
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir, device_id="frame-a")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
target = clips.clip_local_path("1/clips/clip_620/video/bg", "frame-a", self.dir)
|
||||
self.assertEqual(got["file"], str(target))
|
||||
self.assertEqual(set(got), {"message", "file"})
|
||||
self.assertEqual(target.read_bytes(), b"mp4")
|
||||
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
|
||||
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
|
||||
self.assertEqual(calls[2][1], clips.remove_command(path))
|
||||
self.assertEqual(list(self.dir.rglob("*.mp4")), [target])
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
@@ -292,62 +277,20 @@ class ComputerSide(unittest.TestCase):
|
||||
raise RuntimeError("connection lost")
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir, device_id="frame-a")
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
|
||||
self.assertEqual(list(self.dir.iterdir()), [])
|
||||
self.assertEqual(ran, [clips.remove_command(path)])
|
||||
|
||||
def test_same_names_keep_each_account_headset_kind_and_nested_session(self):
|
||||
first = "1/clips/clip_620/video/bg"
|
||||
ids = [first, "2/clips/clip_620/video/bg", "1/clips/clip_620/video/other",
|
||||
"1/clips/clip_620/other/bg", "1/video/clip_620"]
|
||||
legacy = write(self.dir / "clip_620.mp4", b"legacy export")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
frame = lambda args, timeout: {"clips": [{"id": cid} for cid in ids]}
|
||||
stored = {}
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.assertFalse(any(c["saved"] for c in clips.list_clips(
|
||||
frame, folder=self.dir, device_id=device)["clips"]))
|
||||
for index, cid in enumerate(ids):
|
||||
payload = f"{device} session {index}".encode()
|
||||
result = clips.export_clip(cid, lambda a, t: {"path": remote, "size": len(payload)},
|
||||
lambda c, t: None, lambda r, p: p.write_bytes(payload),
|
||||
folder=self.dir, device_id=device)
|
||||
stored[Path(result["file"])] = payload
|
||||
listing = clips.list_clips(frame, folder=self.dir, device_id=device)
|
||||
self.assertEqual([c["saved"] for c in listing["clips"]],
|
||||
[n <= index for n in range(len(ids))])
|
||||
self.assertEqual(listing["folder"], str(self.dir))
|
||||
self.assertEqual(len(stored), len(ids) * 2)
|
||||
for path, payload in stored.items():
|
||||
self.assertEqual(path.read_bytes(), payload)
|
||||
self.assertEqual(legacy.read_bytes(), b"legacy export")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_failed_reexport_preserves_completed_export(self):
|
||||
cid = "1/clips/clip_620/video/bg"
|
||||
target = write(clips.clip_local_path(cid, "frame-a", self.dir), b"completed")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
|
||||
def fetch(remote, local):
|
||||
local.write_bytes(b"partial")
|
||||
raise RuntimeError("connection lost")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip(cid, lambda a, t: {"path": remote}, lambda c, t: None, fetch,
|
||||
folder=self.dir, device_id="frame-a")
|
||||
self.assertEqual(target.read_bytes(), b"completed")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_bad_id_never_reaches_the_frame(self):
|
||||
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir, device_id="frame-a")
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
|
||||
|
||||
def test_unexpected_path_from_the_frame_refused(self):
|
||||
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
|
||||
folder=self.dir, device_id="frame-a")
|
||||
folder=self.dir)
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
|
||||
+56
-12
@@ -16,9 +16,9 @@ saved Steam clips to ~/Videos/SteamFrame.
|
||||
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
|
||||
|
||||
The server supplies how to reach the headset, so this can be tested without one:
|
||||
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
|
||||
list_shots(device) -> {"shots": [{"id", "time", "saved"}...]}
|
||||
save_shots(device, ids) copies those screenshots here
|
||||
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
list_clips(device) -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
export_clip(device, id) exports one clip here
|
||||
device() -> the connected headset's id, or None
|
||||
busy() -> True while other work runs on the headset (installs, jobs, a recording)
|
||||
@@ -41,7 +41,7 @@ SETTLE = 30 # a clip written to within this many seconds is still being recorde
|
||||
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
|
||||
KEEP = 5000 # copied ids remembered per headset and kind
|
||||
BATCH = 100 # screenshots per copy
|
||||
TRIES = 3 # an item that fails this often is left until the app restarts
|
||||
TRIES = 3 # failed attempts per item until Resume, Copy everything now, or restart
|
||||
KINDS = ("shots", "clips")
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ class AutoSync:
|
||||
self._running = False
|
||||
self._backoff = 0
|
||||
self._tries = {} # (device, id) -> failed attempts this run
|
||||
self._failures = {} # last error for each failed item, including exhausted retries
|
||||
self._errors = [] # this pass's, for the page
|
||||
self._s = self._load()
|
||||
|
||||
@@ -143,13 +144,19 @@ class AutoSync:
|
||||
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
|
||||
with self._lock:
|
||||
self._all, self._s["paused"] = True, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._s["error"] = None
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
def resume(self):
|
||||
with self._lock:
|
||||
self._s["paused"], self._s["error"] = None, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
@@ -215,6 +222,9 @@ class AutoSync:
|
||||
|
||||
def _mark(self, device, kind, ids):
|
||||
with self._lock:
|
||||
for item in ids:
|
||||
self._tries.pop((device, item), None)
|
||||
self._failures.pop((device, item), None)
|
||||
per = self._s["synced"].setdefault(device, {})
|
||||
have = per.get(kind, [])
|
||||
new = [i for i in ids if i not in set(have)]
|
||||
@@ -233,24 +243,46 @@ class AutoSync:
|
||||
"then press Resume.")
|
||||
if self.offline(error):
|
||||
raise error
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
message = f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}"
|
||||
if self._tries[key] >= TRIES:
|
||||
message += ". Automatic retries stopped. Press Resume to try again."
|
||||
self._failures[key] = message
|
||||
self._errors.append(message)
|
||||
|
||||
def _retry_allowed(self, device, item):
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
if self._tries.get(key, 0) < TRIES:
|
||||
return True
|
||||
self._errors.append(self._failures[key])
|
||||
return False
|
||||
|
||||
def _interrupted(self, device):
|
||||
return self.busy() or self.device() != device
|
||||
|
||||
def _pass(self, device, shots, clips, since):
|
||||
"""-> (copied, clips still being written, finished without stopping for other work)."""
|
||||
copied = waiting = 0
|
||||
if shots:
|
||||
listing = self.list_shots()["shots"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = self.list_shots(device)["shots"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
done = self._synced(device, "shots")
|
||||
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
|
||||
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
|
||||
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
|
||||
and self._tries.get((device, s["id"]), 0) < TRIES]
|
||||
and self._retry_allowed(device, s["id"])]
|
||||
for i in range(0, len(todo), BATCH):
|
||||
if self.busy():
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
self._room(self.shots_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
batch = todo[i:i + BATCH]
|
||||
try:
|
||||
self.save_shots(device, batch)
|
||||
@@ -258,28 +290,40 @@ class AutoSync:
|
||||
for item in batch:
|
||||
self._failed(device, item, e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "shots", batch)
|
||||
copied += len(batch)
|
||||
if clips:
|
||||
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = [c for c in self.list_clips(device)["clips"] if c.get("kind") == "clip"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
done = self._synced(device, "clips")
|
||||
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
|
||||
for c in sorted(listing, key=lambda c: c["time"]):
|
||||
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
|
||||
if c.get("saved"):
|
||||
continue
|
||||
if since is not None and (c["time"] < since or c["id"] in done):
|
||||
continue
|
||||
if not self._retry_allowed(device, c["id"]):
|
||||
continue
|
||||
if c.get("idle") is None or c["idle"] < SETTLE:
|
||||
waiting += 1 # still being written: copied on a later look
|
||||
continue
|
||||
if self.busy():
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
self._room(self.clips_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
try:
|
||||
self.export_clip(device, c["id"])
|
||||
except Exception as e:
|
||||
self._failed(device, c["id"], e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "clips", [c["id"]])
|
||||
copied += 1
|
||||
return copied, waiting, True
|
||||
+7
-20
@@ -26,7 +26,6 @@ that stream. The folder names are as users and Steam's own files describe them;
|
||||
listing doesn't depend on them: any folder under clips/ or video/ (up to four levels)
|
||||
holding init-stream*.m4s counts as a session.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -364,20 +363,10 @@ def parse_id(clip_id):
|
||||
|
||||
|
||||
def export_name(clip_id):
|
||||
"""The .mp4's basename; it alone does not identify a saved export."""
|
||||
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
|
||||
return parse_id(clip_id)[2] + ".mp4"
|
||||
|
||||
|
||||
def clip_local_path(clip_id, device_id, folder=None):
|
||||
"""Keep the headset, account and full Steam session identity in the destination."""
|
||||
parts = parse_id(clip_id)
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
raise ValueError("missing clip headset identity")
|
||||
headset = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:12]
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
return folder / f"headset-{headset}" / Path(*parts) / export_name(clip_id)
|
||||
|
||||
|
||||
def read_session(folder):
|
||||
"""{stream number: [init, chunk, chunk...]} for one session folder, chunks in number
|
||||
order. A stream without its init segment or without chunks can't be played, so it's left out."""
|
||||
@@ -563,20 +552,19 @@ def remove_command(path):
|
||||
# run_frame(args, timeout) runs this file on the Frame with `args` and returns its JSON;
|
||||
# run(command, timeout) and fetch(remote, local) as for Recorder.
|
||||
|
||||
def list_clips(run_frame, folder=None, *, device_id):
|
||||
def list_clips(run_frame, folder=None):
|
||||
"""Steam's recordings on the Frame, each marked saved if its .mp4 is already here."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
clips = run_frame(["list"], 60)["clips"]
|
||||
for c in clips:
|
||||
c["saved"] = clip_local_path(c["id"], device_id, folder).is_file()
|
||||
c["saved"] = (folder / export_name(c["id"])).exists()
|
||||
return {"clips": clips, "folder": str(folder)}
|
||||
|
||||
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
target = clip_local_path(clip_id, device_id, folder) # validate before reaching the Frame
|
||||
name = target.name
|
||||
name = export_name(clip_id) # checks the id before anything reaches the Frame
|
||||
made = run_frame(["export", clip_id], 1900)
|
||||
path = made.get("path")
|
||||
remove = remove_command(path) # refuses a path that isn't one of our exports
|
||||
@@ -585,8 +573,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
|
||||
try:
|
||||
fetch(path, incoming / name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(incoming / name, target)
|
||||
os.replace(incoming / name, folder / name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
finally:
|
||||
@@ -594,7 +581,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
run(remove, 30)
|
||||
except Exception:
|
||||
pass # the next export sweeps it
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(target)}
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
|
||||
|
||||
|
||||
# ---- on the Frame -----------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -5610,7 +5610,7 @@ const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium
|
||||
function renderAutoSync(s) {
|
||||
autoSync.s = s;
|
||||
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
|
||||
$("syncResume").hidden = !s.paused;
|
||||
$("syncResume").hidden = !(s.paused || s.error);
|
||||
const on = s.screenshots || s.clips, lines = [];
|
||||
if (s.paused) lines.push(s.paused);
|
||||
else if (s.error) lines.push(`Last problem: ${s.error}`);
|
||||
|
||||
+6
-10
@@ -928,9 +928,7 @@ def _clips_frame(args, timeout):
|
||||
def list_clips():
|
||||
_clips_here()
|
||||
try:
|
||||
device = agent_target()["id"]
|
||||
with working(device):
|
||||
return frame_clips.list_clips(_clips_frame, device_id=device)
|
||||
return frame_clips.list_clips(_clips_frame)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
@@ -940,13 +938,11 @@ def export_clip(body):
|
||||
_clips_here()
|
||||
clip_id = body.get("id")
|
||||
name = frame_clips.export_name(clip_id) # ValueError, so 400, for a bad id
|
||||
device = agent_target()["id"]
|
||||
|
||||
def work():
|
||||
try:
|
||||
with working(device):
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file, device_id=device)
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
return start_job(f"Export {name}", work)
|
||||
@@ -969,12 +965,12 @@ def _sync_shots(device, ids):
|
||||
def _sync_clip(device, clip_id):
|
||||
with working(device):
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file, device_id=device)
|
||||
fetch_file)
|
||||
|
||||
|
||||
def _sync_list(listing):
|
||||
def run():
|
||||
with working(_sync_device()):
|
||||
def run(device):
|
||||
with working(device):
|
||||
return listing()
|
||||
return run
|
||||
|
||||
|
||||
Reference in new issue
Block a user