mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 16:02:38 +02:00
Settings → Copy to this computer, off by default, with a switch each for screenshots and Steam clips. While a headset is connected, a worker in the server looks about once a minute and copies what's new with save_shots and frame_clips.export_clip: only what's newer than turning it on, never what it copied before (remembered per headset in auto-sync.json, so deleted files stay deleted), never Steam's background recording or a clip written to in the last 30 s. It waits for other work on the headset, backs off to 15 minutes while the headset is unreachable, and pauses when this computer is out of space. Copy everything now copies the rest once. The Frame-side clip listing now reports each recording's idle time, by the Frame's clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
|
|
|
|
The headset is a fake: lists of shots and clips, and save/export functions that
|
|
record what they were asked to copy. No SSH, no headset.
|
|
"""
|
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
|
import errno
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
|
import frame_autosync as fa # noqa: E402
|
|
|
|
NOW = 1_800_000_000.0
|
|
GB = 1024**3
|
|
|
|
|
|
def shot(n, t, saved=False):
|
|
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
|
|
|
|
|
|
def clip(n, t, idle=600, kind="clip", saved=False):
|
|
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
|
|
|
|
|
|
class FakeFrame:
|
|
def __init__(self):
|
|
self.shots, self.clips = [], []
|
|
self.saved, self.exported, self.calls = [], [], []
|
|
self.device, self.busy, self.down = "frame-a", False, False
|
|
self.save_error = self.export_error = None
|
|
|
|
def list_shots(self):
|
|
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):
|
|
self.calls.append("list_clips")
|
|
if self.down:
|
|
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
|
return {"clips": list(self.clips)}
|
|
|
|
def save_shots(self, device, ids):
|
|
self.calls.append("save_shots")
|
|
if self.save_error:
|
|
raise self.save_error
|
|
self.saved.append((device, list(ids)))
|
|
for s in self.shots:
|
|
if s["id"] in ids:
|
|
s["saved"] = True
|
|
|
|
def export_clip(self, device, clip_id):
|
|
self.calls.append("export_clip")
|
|
if self.export_error:
|
|
raise self.export_error
|
|
self.exported.append((device, clip_id))
|
|
for c in self.clips:
|
|
if c["id"] == clip_id:
|
|
c["saved"] = True
|
|
|
|
|
|
class AutoSyncTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dir = Path(tempfile.mkdtemp())
|
|
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
|
self.frame = FakeFrame()
|
|
self.now = [NOW]
|
|
self.free = [100 * GB]
|
|
self.sync = self.make()
|
|
|
|
def make(self):
|
|
f = self.frame
|
|
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
|
|
lambda: f.device, lambda: f.busy,
|
|
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
|
|
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
|
|
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
|
|
space=lambda folder: self.free[0])
|
|
|
|
def test_off_by_default_and_does_nothing(self):
|
|
self.frame.shots = [shot(1, NOW + 10)]
|
|
self.frame.clips = [clip(1, NOW + 10)]
|
|
s = self.sync.state()
|
|
self.assertFalse(s["screenshots"] or s["clips"])
|
|
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
|
self.assertEqual(self.frame.calls, []) # not even a listing
|
|
self.assertFalse((self.dir / "auto-sync.json").exists())
|
|
|
|
def test_settings_persist(self):
|
|
self.sync.update({"screenshots": True})
|
|
again = self.make().state()
|
|
self.assertTrue(again["screenshots"])
|
|
self.assertFalse(again["clips"])
|
|
self.assertEqual(again["since"], NOW)
|
|
saved = json.loads((self.dir / "auto-sync.json").read_text())
|
|
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
|
|
|
|
def test_only_what_is_newer_than_turning_it_on(self):
|
|
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
|
|
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
|
|
self.sync.update({"screenshots": True, "clips": True})
|
|
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
|
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
|
|
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
|
|
s = self.sync.state()
|
|
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
|
|
|
|
def test_watermark_moves_when_turned_off_and_on_again(self):
|
|
self.sync.update({"screenshots": True})
|
|
self.now[0] += 1000
|
|
self.sync.update({"clips": True}) # already on: the watermark stays
|
|
self.assertEqual(self.sync.state()["since"], NOW)
|
|
self.sync.update({"screenshots": False, "clips": False})
|
|
self.sync.update({"screenshots": True})
|
|
self.assertEqual(self.sync.state()["since"], NOW + 1000)
|
|
|
|
def test_copy_everything_now_once(self):
|
|
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
|
|
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
|
|
self.sync.copy_all() # works with the toggles off too
|
|
self.assertTrue(self.sync.state()["pending"])
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
|
|
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
|
|
self.assertFalse(self.sync.state()["pending"])
|
|
self.frame.calls.clear()
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.calls, []) # off again afterwards
|
|
|
|
def test_not_copied_again_after_deleting_it_here(self):
|
|
self.sync.update({"screenshots": True, "clips": True})
|
|
self.frame.shots = [shot(1, NOW + 5)]
|
|
self.frame.clips = [clip(1, NOW + 5)]
|
|
self.sync.tick()
|
|
for s in self.frame.shots + self.frame.clips:
|
|
s["saved"] = False # the person deleted the files
|
|
self.frame.saved.clear()
|
|
self.frame.exported.clear()
|
|
self.make().tick() # remembered across restarts too
|
|
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
|
|
|
def test_saved_by_hand_counts_as_copied(self):
|
|
self.sync.update({"screenshots": True})
|
|
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
|
self.sync.tick()
|
|
self.frame.shots[0]["saved"] = False
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.saved, [])
|
|
|
|
def test_remembered_per_headset(self):
|
|
self.sync.update({"screenshots": True})
|
|
self.frame.shots = [shot(1, NOW + 5)]
|
|
self.sync.tick()
|
|
self.frame.device = "frame-b"
|
|
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
|
|
self.sync.tick()
|
|
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
|
|
|
|
def test_clip_still_being_recorded_waits(self):
|
|
self.sync.update({"clips": True})
|
|
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.exported, [])
|
|
self.assertEqual(self.sync.state()["waiting"], 2)
|
|
self.frame.clips[0]["idle"] = fa.SETTLE + 1
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
|
|
self.assertEqual(self.sync.state()["waiting"], 1)
|
|
|
|
def test_no_headset_no_work(self):
|
|
self.sync.update({"screenshots": True, "clips": True})
|
|
self.frame.device = None
|
|
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
|
self.assertEqual(self.frame.calls, [])
|
|
|
|
def test_waits_for_other_work(self):
|
|
self.sync.update({"screenshots": True, "clips": True})
|
|
self.frame.shots = [shot(1, NOW + 5)]
|
|
self.frame.busy = True
|
|
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
|
self.assertEqual(self.frame.calls, [])
|
|
self.frame.busy = False
|
|
self.sync.tick()
|
|
self.assertEqual(len(self.frame.saved), 1)
|
|
|
|
def test_stops_between_items_when_other_work_starts(self):
|
|
self.sync.update({"clips": True})
|
|
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
|
|
export = self.frame.export_clip
|
|
|
|
def then_busy(device, clip_id):
|
|
export(device, clip_id)
|
|
self.frame.busy = True # an install started meanwhile
|
|
self.frame.export_clip = then_busy
|
|
sync = self.make()
|
|
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
|
|
self.assertEqual(len(self.frame.exported), 1)
|
|
|
|
def test_backs_off_while_unreachable(self):
|
|
self.sync.update({"screenshots": True})
|
|
self.frame.down = True
|
|
waits = [self.sync.tick() for _ in range(6)]
|
|
self.assertEqual(waits[:4], [120, 240, 480, 900])
|
|
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
|
|
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
|
|
self.frame.down = False
|
|
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
|
self.assertIsNone(self.sync.state()["error"])
|
|
self.frame.down = True
|
|
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
|
|
|
|
def test_pauses_when_this_computer_is_nearly_full(self):
|
|
self.sync.update({"screenshots": True})
|
|
self.frame.shots = [shot(1, NOW + 5)]
|
|
self.free[0] = 10 * 1024**2
|
|
self.sync.tick()
|
|
s = self.sync.state()
|
|
self.assertIn("out of space", s["paused"])
|
|
self.assertEqual(self.frame.saved, [])
|
|
self.free[0] = 100 * GB
|
|
self.frame.calls.clear()
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
|
|
self.assertTrue(self.make().state()["paused"])
|
|
self.sync.resume()
|
|
self.sync.tick()
|
|
self.assertEqual(len(self.frame.saved), 1)
|
|
self.assertIsNone(self.sync.state()["paused"])
|
|
|
|
def test_pauses_when_a_copy_runs_out_of_space(self):
|
|
self.sync.update({"screenshots": True, "clips": True})
|
|
self.frame.clips = [clip(1, NOW + 5)]
|
|
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
|
|
self.sync.tick()
|
|
self.assertIn("ran out of space", self.sync.state()["paused"])
|
|
self.sync.resume()
|
|
self.frame.export_error = None
|
|
self.frame.shots = [shot(1, NOW + 5)]
|
|
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
|
|
self.sync.tick()
|
|
self.assertTrue(self.sync.state()["paused"])
|
|
|
|
def test_space_on_the_frame_is_not_a_pause(self):
|
|
self.sync.update({"clips": True})
|
|
self.frame.clips = [clip(1, NOW + 5)]
|
|
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
|
|
self.sync.tick()
|
|
s = self.sync.state()
|
|
self.assertIsNone(s["paused"])
|
|
self.assertIn("Not enough free space on the Frame", s["error"])
|
|
|
|
def test_an_item_that_keeps_failing_is_left_alone(self):
|
|
self.sync.update({"clips": True})
|
|
self.frame.clips = [clip(1, NOW + 5)]
|
|
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
|
|
for _ in range(fa.TRIES + 2):
|
|
self.sync.tick()
|
|
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
|
|
|
def test_worker_stops(self):
|
|
stop = threading.Event()
|
|
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
|
|
t.start()
|
|
stop.set()
|
|
self.sync.update({}) # wakes it
|
|
t.join(5)
|
|
self.assertFalse(t.is_alive())
|
|
|
|
|
|
class Server(unittest.TestCase):
|
|
def test_route_and_settings(self):
|
|
import server
|
|
self.assertIn("/api/autosync", server.POST)
|
|
with self.assertRaises(server.Failure):
|
|
server.auto_sync({"action": "explode"})
|
|
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
|
|
self.assertFalse(state["screenshots"])
|
|
if server.LINK is None:
|
|
with self.assertRaises(server.Failure):
|
|
server.auto_sync({"action": "now"}) # nothing connected
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|