Compare commits

...

10 Commits

5 changed files with 125 additions and 100 deletions

View File

@@ -6,3 +6,9 @@ rewind is a lightweight, disk-backed replay recording tool for OBS that continuo
2) Enable OBS websocket and take note of the host, port and password 2) Enable OBS websocket and take note of the host, port and password
3) Setup config.toml with host, port, password info 3) Setup config.toml with host, port, password info
4) Run the daemon as a background service (rewind-daemon) 4) Run the daemon as a background service (rewind-daemon)
# Why OBS?
Instead of implementing a custom screen recorder, rewind uses OBS as the backend recorder. Rationale:
- Cross-platform video capture is complex (platform APIs, drivers, hardware acceleration, A/V sync).
- Implementing a custom recorder is out of scope and would duplicate mature functionality.
- OBS provides a stable, production-grade capture and segmentation pipeline.

View File

@@ -2,9 +2,8 @@
import os import os
import datetime import datetime
import subprocess import subprocess
import json
from rewind.state import load_state from rewind.state import load_state, add_marker_to_state, remove_marker_from_state
from rewind.paths import load_config from rewind.paths import load_config
from tqdm import tqdm from tqdm import tqdm
@@ -52,34 +51,11 @@ def mark(name: str) -> None:
if not name: if not name:
raise ValueError("Marker name cannot be empty") raise ValueError("Marker name cannot be empty")
if marker_exists(name): add_marker_to_state(name)
raise ValueError("Marker name already exists")
# writes marker to json file (not state)
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
if os.path.exists(markers_file):
with open(markers_file, "r") as f:
markers = json.load(f)
else:
markers = []
markers.append({
"name": name,
"timestamp": datetime.datetime.now().timestamp()
})
with open(markers_file, "w") as f:
json.dump(markers, f, indent=4)
print(f"Added marker: {name}") print(f"Added marker: {name}")
def get_marker_timestamp(name: str) -> float: def get_marker_timestamp(name: str) -> float:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json") markers = load_state().get("markers", [])
if not os.path.exists(markers_file):
raise RuntimeError("No markers found")
with open(markers_file, "r") as f:
markers = json.load(f)
for marker in markers: for marker in markers:
if marker["name"] == name: if marker["name"] == name:
@@ -88,40 +64,21 @@ def get_marker_timestamp(name: str) -> float:
raise ValueError("Marker name does not exist") raise ValueError("Marker name does not exist")
def print_markers() -> None: def print_markers() -> None:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json") markers = load_state().get("markers", [])
if not os.path.exists(markers_file):
print("No markers found.")
return
with open(markers_file, "r") as f: if markers == []:
markers = json.load(f) print("No markers exist.")
for marker in markers: for marker in markers:
format_time = datetime.datetime.fromtimestamp(marker['timestamp']).strftime('%Y-%m-%d %H:%M:%S') format_time = datetime.datetime.fromtimestamp(marker['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
print(f"{format_time} -> {marker['name']}") print(f"{format_time} -> {marker['name']}")
def remove_marker(name: str) -> None: def remove_marker(name: str) -> None:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json") remove_marker_from_state(name)
if not os.path.exists(markers_file):
raise RuntimeError("No markers found")
with open(markers_file, "r") as f:
markers = json.load(f)
markers = [m for m in markers if m["name"] != name]
with open(markers_file, "w") as f:
json.dump(markers, f, indent=4)
print(f"Removed marker: {name}") print(f"Removed marker: {name}")
def marker_exists(name: str) -> bool: def marker_exists(name: str) -> bool:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json") markers = load_state().get("markers", [])
if not os.path.exists(markers_file):
return False
with open(markers_file, "r") as f:
markers = json.load(f)
for marker in markers: for marker in markers:
if marker["name"] == name: if marker["name"] == name:

View File

@@ -5,16 +5,15 @@ import time
import obsws_python as obs import obsws_python as obs
import subprocess import subprocess
import logging import logging
import json
import shutil import shutil
import signal
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from rewind.paths import load_config from rewind.paths import load_config
from rewind.core import mark, marker_exists, remove_marker from rewind.core import mark, marker_exists, remove_marker
from rewind.state import add_file_to_state, create_state_file_if_needed, cleanup_state_files from rewind.state import add_file_to_state, create_state_file_if_needed, cleanup_state
INTERVAL = 10
running = True running = True
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,14 +21,19 @@ logger.setLevel(logging.DEBUG)
logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s") logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s")
logging.getLogger("obsws_python").setLevel(logging.CRITICAL) logging.getLogger("obsws_python").setLevel(logging.CRITICAL)
INTERVAL = 10
SENTINEL_FILE = os.path.expanduser("~/.config/obs-studio/.sentinel") SENTINEL_FILE = os.path.expanduser("~/.config/obs-studio/.sentinel")
OBS_MAX_RETRIES = 10 OBS_MAX_RETRIES = 10
def open_obs(): def shutdown(signum, frame):
kill_command = subprocess.run(['pkill', 'obs']) global running
if kill_command.returncode not in [0, 1]: logger.info(f"Received signal {signum}, shutting down cleanly")
raise SystemError("Could not kill existing OBS instance") running = False
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
def clean_obs_sentinel_files():
if os.path.exists(SENTINEL_FILE): if os.path.exists(SENTINEL_FILE):
try: try:
shutil.rmtree(SENTINEL_FILE) shutil.rmtree(SENTINEL_FILE)
@@ -37,6 +41,14 @@ def open_obs():
except Exception as e: except Exception as e:
logger.error(f"Could not delete OBS .sentinel directory: {e}") logger.error(f"Could not delete OBS .sentinel directory: {e}")
def is_obs_running():
result = subprocess.run(
["pgrep", "-x", "obs"],
stdout=subprocess.PIPE
)
return result.returncode == 0
def open_obs():
# Using and not checking OBS since it needs to be non-blocking # Using and not checking OBS since it needs to be non-blocking
subprocess.Popen(["obs", "--minimize-to-tray"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.Popen(["obs", "--minimize-to-tray"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -49,6 +61,8 @@ def open_obs_connection(host: str, port: int, password: str) -> obs.ReqClient:
con = obs.ReqClient(host=host, port=port, password=password) con = obs.ReqClient(host=host, port=port, password=password)
except ConnectionRefusedError: except ConnectionRefusedError:
logger.info("OBS WebSocket not ready, retrying...") logger.info("OBS WebSocket not ready, retrying...")
except obs.events.OBSSDKError:
raise RuntimeError("Check OBS credentials")
if con: if con:
logger.info(f"Successfully connected to OBS at {host}:{port}") logger.info(f"Successfully connected to OBS at {host}:{port}")
@@ -83,23 +97,6 @@ def cleanup_physical_files(directory: str, max_age_seconds: int) -> None:
os.remove(file_path) os.remove(file_path)
logger.info(f"Removed old file: {file_path}") logger.info(f"Removed old file: {file_path}")
def cleanup_markers(max_age_seconds: float) -> None:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
if not os.path.exists(markers_file):
return
with open(markers_file, "r") as f:
markers = json.load(f)
current_time = datetime.datetime.now().timestamp()
new_markers = [m for m in markers if current_time - m['timestamp'] <= max_age_seconds]
with open(markers_file, "w") as f:
json.dump(new_markers, f, indent=4)
if new_markers != markers:
logger.info(f"Cleaning up {len(markers)-len(new_markers)} markers")
class Handler(FileSystemEventHandler): class Handler(FileSystemEventHandler):
def on_created(self, event): def on_created(self, event):
if event.is_directory: if event.is_directory:
@@ -111,7 +108,13 @@ class Handler(FileSystemEventHandler):
logger.info(f"Added new file to state: {event.src_path}") logger.info(f"Added new file to state: {event.src_path}")
def main() -> None: def main() -> None:
if is_obs_running():
logger.info("OBS is already running")
else:
logger.info("OBS is not running, starting it now")
clean_obs_sentinel_files()
open_obs() open_obs()
config = load_config() config = load_config()
con = open_obs_connection(config["obs"]["host"], config["obs"]["port"], config["obs"]["password"]) con = open_obs_connection(config["obs"]["host"], config["obs"]["port"], config["obs"]["password"])
@@ -129,10 +132,13 @@ def main() -> None:
while running: while running:
cleanup_physical_files(recording_dir, config["record"]["max_record_time"]) cleanup_physical_files(recording_dir, config["record"]["max_record_time"])
cleanup_state_files() cleanup_state(config["record"]["max_record_time"])
cleanup_markers(config["record"]["max_record_time"])
time.sleep(INTERVAL) time.sleep(INTERVAL)
finally: finally:
if observer:
observer.stop()
observer.join()
stop_recording(con) stop_recording(con)
con.disconnect() con.disconnect()
logger.info("Daemon stopped") logger.info("Daemon stopped")

View File

@@ -6,7 +6,6 @@ from importlib import resources
APP_NAME = "rewind" APP_NAME = "rewind"
CONFIG_NAME = "config.toml" CONFIG_NAME = "config.toml"
STATE_NAME = "state.json"
def get_config_dir() -> Path: def get_config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
@@ -26,3 +25,8 @@ def load_config() -> dict:
with config_file.open("rb") as f: with config_file.open("rb") as f:
return tomllib.load(f) return tomllib.load(f)
def get_state_dir() -> Path:
base = os.path.expanduser("~/.local/share")
path = Path(base) / APP_NAME
path.mkdir(parents=True, exist_ok=True)
return path

View File

@@ -4,26 +4,29 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from rewind.paths import get_state_dir
APP_NAME = "rewind"
STATE_NAME = "state.json" STATE_NAME = "state.json"
EMPTY_STATE = {
def state_dir() -> Path: "files": [],
base = os.path.expanduser("~/.local/share") "markers": []
path = Path(base) / APP_NAME }
path.mkdir(parents=True, exist_ok=True)
return path
def get_state_file_path() -> Path: def get_state_file_path() -> Path:
return state_dir() / STATE_NAME return get_state_dir() / STATE_NAME
def load_state() -> dict: def load_state() -> dict:
if not get_state_file_path().exists(): path = get_state_file_path()
return {"files": []} if not path.exists():
with get_state_file_path().open() as f: return EMPTY_STATE.copy()
with path.open() as f:
return json.load(f) return json.load(f)
def write_state(state: dict) -> None: def write_state(state: dict) -> None:
if "files" not in state or "markers" not in state:
raise ValueError("Invalid state configuration. State must contain 'files' and 'markers' keys.")
tmp = get_state_file_path().with_suffix(".tmp") tmp = get_state_file_path().with_suffix(".tmp")
with tmp.open("w") as f: with tmp.open("w") as f:
json.dump(state, f, indent=2) json.dump(state, f, indent=2)
@@ -33,6 +36,11 @@ def add_file_to_state(file_path: str) -> None:
state = load_state() state = load_state()
files = state.get("files", []) files = state.get("files", [])
# Idempotency guard
existing_paths = {f["path"] for f in files}
if file_path in existing_paths:
return
files.append({ files.append({
"path": file_path, "path": file_path,
"timestamp": datetime.datetime.now().timestamp(), "timestamp": datetime.datetime.now().timestamp(),
@@ -41,15 +49,59 @@ def add_file_to_state(file_path: str) -> None:
state["files"] = files state["files"] = files
write_state(state) write_state(state)
def cleanup_state_files() -> None: def add_marker_to_state(marker_name: str) -> None:
state = load_state() state = load_state()
files = state.get("files", []) markers = state.get("markers", [])
existing_markers = {m["name"] for m in markers}
if marker_name in existing_markers:
raise ValueError("Marker already exists")
markers.append({
"name": marker_name,
"timestamp": datetime.datetime.now().timestamp()
})
state["markers"] = markers
write_state(state)
def remove_marker_from_state(marker_name: str) -> None:
state = load_state()
markers = state.get("markers", [])
markers = [marker for marker in markers if marker["name"] != marker_name]
state["markers"] = markers
write_state(state)
def cleanup_state(max_age_seconds: float) -> None:
state = load_state()
now = datetime.datetime.now().timestamp()
files = state.get("files", [])
markers = state.get("markers", [])
# First filter by existence
files = [
f for f in files
if os.path.exists(f["path"])
]
# Then filter by age
files = [
f for f in files
if now - f["timestamp"] <= max_age_seconds
]
markers = [
m for m in markers
if now - m["timestamp"] <= max_age_seconds
]
state["files"] = files
state["markers"] = markers
# Remove old files from state
state["files"] = [file for file in files if os.path.exists(file["path"])]
write_state(state) write_state(state)
def create_state_file_if_needed() -> None: def create_state_file_if_needed() -> None:
if not os.path.exists(get_state_file_path()): if not get_state_file_path().exists():
state = {"files": []} write_state(EMPTY_STATE.copy())
write_state(state)