Compare commits

...

31 Commits

Author SHA1 Message Date
87d1491f48 refactor(daemon): use obs-events for file fetching
Instead of depending on watchdog, use the OBS event which is faster.
2026-04-30 22:42:55 +01:00
d722935020 fix(state): ensure files are sorted by timestamp 2026-04-29 21:31:39 +01:00
96f20a714c refactor(ts_files): move ts file state fetch out of media 2026-04-29 21:31:24 +01:00
7b6780bd86 refactor(clip): extract media functions out of core
This is in preperation for testing
2026-04-29 20:15:35 +01:00
8cd766fc8d refactor(os): rename autostart classes to os classes
These perform OS-specific functions
2026-04-29 20:13:09 +01:00
441dc02080 refactor(autostart): abstract backend implementation for autostart 2026-04-28 22:58:48 +01:00
31e236dc46 fix(clip): use existing config instance for clip output path 2026-04-28 22:40:46 +01:00
3ef9955015 feat(autostart): implement daemon management features in cli 2026-04-28 22:35:03 +01:00
695320d30f feat(paths): add support for macOS and Windows paths 2026-04-28 21:58:18 +01:00
2516b9b299 fix(markers): allow markers to have no name 2026-04-28 21:02:47 +01:00
4121c99bb2 refactor(config): update access methods for configuration properties 2026-04-27 23:36:16 +01:00
b5dbccef85 fix(core): no existence validation of ts files 2026-04-27 23:16:23 +01:00
2ef48609d9 fix(state): shallow copy of empty state 2026-04-27 23:13:36 +01:00
d97f15233b fix(core): temp file written to cwd
Now uses temp file module to prevent race conditions.
2026-04-27 23:13:23 +01:00
e32f214a03 feat(cli): add cli options for changing config 2026-04-27 23:05:22 +01:00
bf29693e5a feat(config): implement configuration management 2026-04-27 23:03:34 +01:00
ef83b7a181 chore: improve logging and remove service existence check
If the service text needs to be updated, the additional check would not update the service.
2026-04-27 22:06:58 +01:00
974251d1db chore(core): validate clip configuration 2026-04-26 23:54:41 +01:00
b3c38c18e0 chore(daemon): daemon logs to file 2026-04-26 23:43:08 +01:00
0af780a95c feat(install): add autostart functionality for rewind daemon 2026-04-26 23:42:45 +01:00
8b3fe98423 feat(defaults): update default clip length to 60s
30 seconds felt ever so slightly too short for the average case, let's
update to 60 and see how it goes.
2026-04-11 22:31:30 +01:00
c58addff26 fix: remove aggressive OBS kill behaviour
Checks for existing OBS process, and tries to connect to that
2026-02-21 21:14:20 +00:00
81a7442d7a fix: add check for state file in load_state 2026-02-19 14:25:37 +00:00
b7bf04638a fix: remove debug print 2026-02-19 14:24:29 +00:00
3b21a5276e fix(state): marker and file cleanup overwriting it's own filtering 2026-02-19 14:22:17 +00:00
8ad9917754 feat(state): combine markers and file tracking into single state file 2026-02-19 14:12:43 +00:00
2bc838fa6d refactor(state): move state directory function into paths module 2026-02-19 12:20:44 +00:00
94758cff4e fix(state): make add_file_to_state idempotent 2026-02-15 20:45:36 +00:00
Dylan De Faoite
a0040b0f43 docs: add OBS rationale 2026-02-10 14:50:02 +00:00
a697ed6643 fix: handle clean shutdown upon SIGTERM 2026-02-05 20:09:56 +00:00
f40714c409 fix: double entry of OBS files into state upon startup 2026-02-05 20:04:38 +00:00
14 changed files with 607 additions and 285 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

@@ -7,7 +7,7 @@ requires-python = ">=3.9"
dependencies = [ dependencies = [
"obsws-python", "obsws-python",
"watchdog", "tomli-w",
"tqdm" "tqdm"
] ]

View File

@@ -1,4 +1,4 @@
obsws-python==1.8.0 obsws-python==1.8.0
tomli-w==1.1.0
tqdm==4.67.2 tqdm==4.67.2
watchdog==6.0.0
websocket-client==1.9.0 websocket-client==1.9.0

View File

@@ -3,14 +3,16 @@ import sys
import argparse import argparse
from rewind.core import clip, mark, save, print_markers from rewind.core import clip, mark, save, print_markers
from rewind.os.main import enable_autostart, disable_autostart, start_daemon, stop_daemon
from rewind.config import Config
def build_clip_parser(subparsers: argparse._SubParsersAction) -> None: def build_clip_parser(subparsers: argparse._SubParsersAction) -> None:
clip_parser = subparsers.add_parser("clip", help="Clips the last 'x' seconds") clip_parser = subparsers.add_parser("clip", help="Clips the last 'x' seconds")
clip_parser.add_argument( clip_parser.add_argument(
"-s", "--seconds", "-s", "--seconds",
type=int, type=int,
default=30, default=60,
help="Number of seconds to include in the clip (default: 30)" help="Number of seconds to include in the clip (default: 60)"
) )
def build_save_parser(subparsers: argparse._SubParsersAction) -> None: def build_save_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -34,13 +36,33 @@ def build_mark_parser(subparsers: argparse._SubParsersAction) -> None:
mark_parser.add_argument( mark_parser.add_argument(
"-n", "--name", "-n", "--name",
type=str, type=str,
required=True, required=False,
help="Name of the marker" help="Name of the marker"
) )
def build_list_parser(subparsers: argparse._SubParsersAction) -> None: def build_list_parser(subparsers: argparse._SubParsersAction) -> None:
list_parser = subparsers.add_parser("list", help="List all markers in the recording") list_parser = subparsers.add_parser("list", help="List all markers in the recording")
def build_daemon_parser(subparsers: argparse._SubParsersAction) -> None:
daemon_parser = subparsers.add_parser("daemon", help="Control the rewind daemon")
daemon_sub = daemon_parser.add_subparsers(dest="daemon_command")
daemon_sub.add_parser("start", help="Start the daemon")
daemon_sub.add_parser("stop", help="Stop the daemon")
daemon_sub.add_parser("enable", help="Enable autostart for the daemon")
daemon_sub.add_parser("disable", help="Disable autostart for the daemon")
def build_config_parser(subparsers: argparse._SubParsersAction) -> None:
config_parser = subparsers.add_parser("config", help="View or edit config values")
config_sub = config_parser.add_subparsers(dest="config_command")
get_parser = config_sub.add_parser("get", help="Print a single config value")
get_parser.add_argument("key", help="Dotted key, e.g. obs.host")
set_parser = config_sub.add_parser("set", help="Update a config value")
set_parser.add_argument("key", help="Dotted key, e.g. obs.host")
set_parser.add_argument("value", help="New value")
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="rewind", prog="rewind",
@@ -53,12 +75,15 @@ def build_parser() -> argparse.ArgumentParser:
build_mark_parser(sub) build_mark_parser(sub)
build_list_parser(sub) build_list_parser(sub)
build_save_parser(sub) build_save_parser(sub)
build_daemon_parser(sub)
build_config_parser(sub)
return parser return parser
def main(argv=None) -> int: def main(argv=None) -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
cfg = Config()
try: try:
if args.command == "clip": if args.command == "clip":
@@ -69,6 +94,24 @@ def main(argv=None) -> int:
print_markers() print_markers()
elif args.command == "save": elif args.command == "save":
save(args.start, args.end) save(args.start, args.end)
elif args.command == "daemon":
if args.daemon_command == "start":
start_daemon()
elif args.daemon_command == "stop":
stop_daemon()
elif args.daemon_command == "enable":
enable_autostart()
elif args.daemon_command == "disable":
disable_autostart()
else:
parser.error("Unknown daemon command: expected 'start' or 'stop'")
elif args.command == "config":
if args.config_command == "set":
cfg.set(args.key, args.value)
elif args.config_command == "get":
print(cfg.get(args.key))
else:
parser.error("Unknown config command: expected 'get' or 'set'")
else: else:
parser.error("Unknown command") parser.error("Unknown command")
except Exception as e: except Exception as e:
@@ -78,4 +121,4 @@ def main(argv=None) -> int:
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

89
rewind/config.py Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
import os
import tomllib
import tomli_w
from pathlib import Path
from rewind.paths import get_config_dir
_CONFIG_NAME = "config.toml"
_EXAMPLE_CONFIG = Path(__file__).parent / "example.config.toml"
class Config:
def __init__(self):
self._config_dir = get_config_dir()
self._config_file = self._config_dir / _CONFIG_NAME
self._data = self._load()
# Private
def _load(self) -> dict:
if not self._config_file.exists():
self._config_dir.mkdir(parents=True, exist_ok=True)
self._config_file.write_text(_EXAMPLE_CONFIG.read_text())
with self._config_file.open("rb") as f:
return tomllib.load(f)
def _save(self) -> None:
with self._config_file.open("wb") as f:
tomli_w.dump(self._data, f)
# Public
def get_obs_host(self) -> str:
return self._data["obs"]["host"]
def set_obs_host(self, value: str) -> None:
self._data["obs"]["host"] = value
self._save()
def get_obs_port(self) -> int:
return self._data["obs"]["port"]
def set_obs_port(self, value: int) -> None:
self._data["obs"]["port"] = value
self._save()
def get_obs_password(self) -> str:
return self._data["obs"]["password"]
def set_obs_password(self, value: str) -> None:
self._data["obs"]["password"] = value
self._save()
def get_max_record_time(self) -> int:
return self._data["record"]["max_record_time"]
def set_max_record_time(self, value: int) -> None:
self._data["record"]["max_record_time"] = value
self._save()
def get_clip_output(self) -> str:
return os.path.expanduser(self._data["record"]["clip_output"])
def set_clip_output(self, value: str) -> None:
self._data["record"]["clip_output"] = value
self._save()
def get_vod_output(self) -> str:
return os.path.expanduser(self._data["record"]["vod_output"])
def set_vod_output(self, value: str) -> None:
self._data["record"]["vod_output"] = value
self._save()
def get(self, key: str) -> str:
section, _, field = key.partition(".")
try:
return str(self._data[section][field])
except KeyError:
raise KeyError(f"Unknown config key: {key!r}")
def set(self, key: str, value: str) -> None:
section, _, field = key.partition(".")
try:
current = self._data[section][field]
except KeyError:
raise KeyError(f"Unknown config key: {key!r}")
self._data[section][field] = type(current)(value)
self._save()

View File

@@ -1,15 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import datetime import datetime
import subprocess
import json
from rewind.state import load_state from rewind.config import Config
from rewind.paths import load_config from rewind.media import get_ts_files, concat_ts_files
from tqdm import tqdm from rewind.state import load_state, add_marker_to_state, remove_marker_from_state
config = Config()
def clip(seconds_from_end: float) -> None: def clip(seconds_from_end: float) -> None:
clip_output = os.path.expanduser(load_config()["record"]["clip_output"]) if seconds_from_end <= 0 or seconds_from_end > 600:
raise ValueError("Clip length must be positive and less than or equal to 10 minutes")
clip_output = config.get_clip_output()
os.makedirs(clip_output, exist_ok=True) os.makedirs(clip_output, exist_ok=True)
output_file_name = f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.mp4" output_file_name = f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.mp4"
@@ -19,67 +22,49 @@ def clip(seconds_from_end: float) -> None:
end_timestamp = datetime.datetime.now().timestamp() end_timestamp = datetime.datetime.now().timestamp()
length = end_timestamp - start_timestamp length = end_timestamp - start_timestamp
files, start_offset, end_offset = _get_ts_files( ts_files = [f for f in load_state()["files"] if os.path.exists(f["path"])]
files, start_offset, end_offset = get_ts_files(
ts_files,
start_timestamp, start_timestamp,
end_timestamp end_timestamp
) )
_concat_ts_files(files, start_offset, end_offset, length, output_path) concat_ts_files(files, start_offset, end_offset, length, output_path)
print(f"Created clip: {output_path}") print(f"Created clip: {output_path}")
def save(first_marker: str, second_marker: str): def save(first_marker: str, second_marker: str):
vod_dir = os.path.expanduser(load_config()["record"]["vod_output"]) vod_dir = config.get_vod_output()
os.makedirs(vod_dir, exist_ok=True) os.makedirs(vod_dir, exist_ok=True)
first_timestamp = get_marker_timestamp(first_marker) first_timestamp = get_marker_timestamp(first_marker)
second_timestamp = get_marker_timestamp(second_marker) second_timestamp = get_marker_timestamp(second_marker)
output_file_name = f"{datetime.datetime.fromtimestamp(first_timestamp).strftime('%Y-%m-%d_%H:%M:%S')}-[{first_marker}-{second_marker}].mp4"
output_path = os.path.join(vod_dir, output_file_name)
if first_timestamp >= second_timestamp: if first_timestamp >= second_timestamp:
raise ValueError("First marker must be before second marker") raise ValueError("First marker must be before second marker")
files, start_offset, end_offset = _get_ts_files( output_file_name = f"{datetime.datetime.fromtimestamp(first_timestamp).strftime('%Y-%m-%d_%H:%M:%S')}-[{first_marker}-{second_marker}].mp4"
output_path = os.path.join(vod_dir, output_file_name)
ts_files = [f for f in load_state()["files"] if os.path.exists(f["path"])]
files, start_offset, end_offset = get_ts_files(
ts_files,
first_timestamp, first_timestamp,
second_timestamp second_timestamp
) )
_concat_ts_files(files, start_offset, end_offset, second_timestamp - first_timestamp, output_path) concat_ts_files(files, start_offset, end_offset, second_timestamp - first_timestamp, output_path)
print(f"Created video file: {output_path}") print(f"Created video file: {output_path}")
def mark(name: str) -> None: def mark(name: str) -> None:
if not name: if not name:
raise ValueError("Marker name cannot be empty") name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
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,144 +73,24 @@ 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:
return True return True
return False return False
"""
Retrieves .ts files recorded between the specified timestamps.
Returns a list of file paths and extra start and end offsets if needed.
get_duration() is used as little as possible since it is slow.
end_timestamp of a file is the start time of the next file.
"""
def _get_ts_files(start_timestamp: float, end_timestamp: float) -> tuple[list[str], float, float]:
ts_files = load_state()["files"]
selected_files = []
start_offset = 0.0
end_offset = 0.0
for i, file_info in enumerate(ts_files):
file_start = file_info["timestamp"]
file_end = ts_files[i + 1]["timestamp"] if i + 1 < len(ts_files) else get_duration(file_info["path"]) + file_start
if file_end <= start_timestamp:
continue
if file_start >= end_timestamp:
break
selected_files.append(file_info["path"])
if file_start <= start_timestamp < file_end:
start_offset = start_timestamp - file_start
if file_start < end_timestamp <= file_end:
end_offset = file_end - end_timestamp
return selected_files, start_offset, end_offset
def _concat_ts_files(file_list: list[str], start_offset: float, end_offset: float, length: float, output_file: str) -> None:
with open("file_list.txt", "w") as f:
for file_path in file_list:
f.write(f"file '{file_path}'\n")
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1"]
if start_offset > 0:
cmd += ["-ss", str(start_offset)]
if end_offset > 0:
cmd += ["-t", str(length)]
cmd += ["-f", "concat", "-safe", "0", "-i", "file_list.txt", "-c", "copy"]
cmd.append(output_file)
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
init_ms_val = 0
init_ms_val_set = False
with tqdm(
total=length,
unit="s",
unit_scale=True,
unit_divisor=60,
desc="Processing",
leave=True,
) as pbar:
for line in process.stdout:
line = line.strip()
if line.startswith("out_time_ms="):
out_time_ms = int(line.split("=")[1])
if not init_ms_val_set:
init_ms_val = out_time_ms
init_ms_val_set = True
out_time_ms -= init_ms_val
seconds = abs(out_time_ms / 1_000_000)
pbar.n = min(seconds, length)
pbar.refresh()
elif line == "progress=end":
break
ret = process.wait()
os.remove("file_list.txt")
if ret != 0:
raise RuntimeError("ffmpeg failed")
def get_duration(file_path: str) -> float:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of",
"default=noprint_wrappers=1:nokey=1", file_path],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed for file {file_path}")
return float(result.stdout)

View File

@@ -3,56 +3,52 @@ import os
import datetime import datetime
import time import time
import obsws_python as obs import obsws_python as obs
import subprocess
import logging import logging
import json import signal
import shutil
from watchdog.observers import Observer from rewind.paths import get_state_dir
from watchdog.events import FileSystemEventHandler from rewind.config import 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
LOG_FILE = get_state_dir() / "daemon.log"
INTERVAL = 10
OBS_MAX_RETRIES = 5
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s") logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s", handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()])
logging.getLogger("obsws_python").setLevel(logging.CRITICAL) logging.getLogger("obsws_python").setLevel(logging.CRITICAL)
SENTINEL_FILE = os.path.expanduser("~/.config/obs-studio/.sentinel") def shutdown(signum, frame):
OBS_MAX_RETRIES = 10 global running
logger.info(f"Received signal {signum}, shutting down cleanly")
running = False
def open_obs(): signal.signal(signal.SIGINT, shutdown)
kill_command = subprocess.run(['pkill', 'obs']) signal.signal(signal.SIGTERM, shutdown)
if kill_command.returncode not in [0, 1]:
raise SystemError("Could not kill existing OBS instance")
if os.path.exists(SENTINEL_FILE): def open_obs_connection(host: str, port: int, password: str, obs_max_retries: int) -> dict[str, obs.ReqClient | obs.EventClient]:
try: req = None
shutil.rmtree(SENTINEL_FILE) event = None
logger.info("Removed existing OBS .sentinel directory")
except Exception as e:
logger.error(f"Could not delete OBS .sentinel directory: {e}")
# Using and not checking OBS since it needs to be non-blocking
subprocess.Popen(["obs", "--minimize-to-tray"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def open_obs_connection(host: str, port: int, password: str) -> obs.ReqClient:
con = None
init_sleep = 1 init_sleep = 1
for _ in range(OBS_MAX_RETRIES): for _ in range(obs_max_retries):
try: try:
con = obs.ReqClient(host=host, port=port, password=password) req = obs.ReqClient(host=host, port=port, password=password)
event = obs.EventClient(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:
if con: raise RuntimeError("Check OBS credentials")
if req and event:
logger.info(f"Successfully connected to OBS at {host}:{port}") logger.info(f"Successfully connected to OBS at {host}:{port}")
return con return {
"req": req,
"event": event
}
time.sleep(init_sleep) time.sleep(init_sleep)
init_sleep *= 2 init_sleep *= 2
@@ -83,58 +79,37 @@ 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: def on_record_file_changed(event) -> None:
markers_file = os.path.join(os.path.dirname(__file__), "markers.json") new_file = event.new_output_path
if not os.path.exists(markers_file): add_file_to_state(new_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):
def on_created(self, event):
if event.is_directory:
return
if not event.src_path.endswith(".ts"):
return
add_file_to_state(event.src_path)
logger.info(f"Added new file to state: {event.src_path}")
def main() -> None: def main() -> None:
open_obs() config = Config()
config = load_config() obs_host = config.get_obs_host()
con = open_obs_connection(config["obs"]["host"], config["obs"]["port"], config["obs"]["password"]) obs_port = config.get_obs_port()
obs_password = config.get_obs_password()
recording_dir = con.get_record_directory().record_directory connections = open_obs_connection(obs_host, obs_port, obs_password, OBS_MAX_RETRIES)
start_recording(con) req_con = connections["req"]
event_con = connections["event"]
event_con.callback.register(on_record_file_changed)
recording_dir = req_con.get_record_directory().record_directory
start_recording(req_con)
create_state_file_if_needed() create_state_file_if_needed()
create_initial_marker() create_initial_marker()
try: try:
event_handler = Handler()
observer = Observer()
observer.schedule(event_handler, path=recording_dir, recursive=False)
observer.start()
while running: while running:
cleanup_physical_files(recording_dir, config["record"]["max_record_time"]) cleanup_physical_files(recording_dir, config.get_max_record_time())
cleanup_state_files() cleanup_state(config.get_max_record_time())
cleanup_markers(config["record"]["max_record_time"])
time.sleep(INTERVAL) time.sleep(INTERVAL)
finally: finally:
stop_recording(con) stop_recording(req_con)
con.disconnect() event_con.disconnect()
req_con.disconnect()
logger.info("Daemon stopped") logger.info("Daemon stopped")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
[obs] [obs]
host = "localhost" host = "localhost"
port = 4455 port = 4455
password = "6BQWrpHLbyJxNLPJ" password = ""
[record] [record]
max_record_time = 28800 max_record_time = 28800

114
rewind/media.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import subprocess
import tempfile
from rewind.state import load_state
from tqdm import tqdm
"""
Retrieves .ts files recorded between the specified timestamps.
Returns a list of file paths and extra start and end offsets if needed.
get_duration() is used as little as possible since it is slow.
end_timestamp of a file is the start time of the next file.
"""
def get_ts_files(
ts_files: list[dict],
start_timestamp: float,
end_timestamp: float
) -> tuple[list[str], float, float]:
selected_files = []
start_offset = 0.0
end_offset = 0.0
for i, file_info in enumerate(ts_files):
file_start = file_info["timestamp"]
file_end = ts_files[i + 1]["timestamp"] if i + 1 < len(ts_files) else get_duration(file_info["path"]) + file_start
if file_end <= start_timestamp:
continue
if file_start >= end_timestamp:
break
selected_files.append(file_info["path"])
if file_start < start_timestamp:
start_offset = start_timestamp - file_start
if file_end >= end_timestamp:
end_offset = file_end - end_timestamp
break
return selected_files, start_offset, end_offset
def concat_ts_files(file_list: list[str], start_offset: float, end_offset: float, length: float, output_file: str) -> None:
tmp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
try:
for file_path in file_list:
tmp_file.write(f"file '{file_path}'\n")
tmp_file.close()
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1"]
if start_offset > 0:
cmd += ["-ss", str(start_offset)]
if end_offset > 0:
cmd += ["-t", str(length)]
cmd += ["-f", "concat", "-safe", "0", "-i", tmp_file.name, "-c", "copy"]
cmd.append(output_file)
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
init_ms_val = 0
init_ms_val_set = False
with tqdm(
total=length,
unit="s",
unit_scale=True,
unit_divisor=60,
desc="Processing",
leave=True,
) as pbar:
for line in process.stdout: # type: ignore[union-attr]
line = line.strip()
if line.startswith("out_time_ms="):
out_time_ms = int(line.split("=")[1])
if not init_ms_val_set:
init_ms_val = out_time_ms
init_ms_val_set = True
out_time_ms -= init_ms_val
seconds = abs(out_time_ms / 1_000_000)
pbar.n = min(seconds, length)
pbar.refresh()
elif line == "progress=end":
break
ret = process.wait()
if ret != 0:
raise RuntimeError("ffmpeg failed")
finally:
os.unlink(tmp_file.name)
def get_duration(file_path: str) -> float:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of",
"default=noprint_wrappers=1:nokey=1", file_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed for file {file_path}")
return float(result.stdout.strip())

15
rewind/os/backend.py Normal file
View File

@@ -0,0 +1,15 @@
class OSBackend:
def __init__(self, program_path: str):
self.program_path = program_path
def enable_autostart(self):
raise NotImplementedError("enable_autostart not implemented for this platform")
def disable_autostart(self):
raise NotImplementedError("disable_autostart not implemented for this platform")
def start_daemon(self):
raise NotImplementedError("start_daemon not implemented for this platform")
def stop_daemon(self):
raise NotImplementedError("stop_daemon not implemented for this platform")

80
rewind/os/linux.py Normal file
View File

@@ -0,0 +1,80 @@
import subprocess
from pathlib import Path
from rewind.os.backend import OSBackend
class LinuxBackend(OSBackend):
def __init__(self, program_path: str):
super().__init__(program_path)
def _run(self, cmd: list[str]):
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{result.stderr}")
return result
def _create_systemd_service(self):
systemd_dir = Path.home() / ".config/systemd/user"
systemd_dir.mkdir(parents=True, exist_ok=True)
obs_service = systemd_dir / "obs.service"
daemon_service = systemd_dir / "rewind-daemon.service"
obs_service.write_text("""[Unit]
Description=OBS Studio
After=graphical-session.target
Wants=graphical-session.target
[Service]
# Ensure OBS sentinel files are cleaned up before starting the daemon
ExecStartPre=-/usr/bin/rm -rf %h/.config/obs-studio/.sentinel
ExecStart=obs --minimize-to-tray
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
""")
daemon_service.write_text(f"""[Unit]
Description=Rewind Daemon
After=obs.service
Requires=obs.service
[Service]
ExecStart={self.program_path}
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=default.target
""")
def enable_autostart(self):
self._create_systemd_service()
self._run(["systemctl", "--user", "daemon-reload"])
self._run(["systemctl", "--user", "enable", "obs.service"])
self._run(["systemctl", "--user", "enable", "rewind-daemon.service"])
print("Installed OBS + rewind-daemon via systemd")
def disable_autostart(self):
self._create_systemd_service()
self._run(["systemctl", "--user", "disable", "rewind-daemon.service"])
self._run(["systemctl", "--user", "disable", "obs.service"])
systemd_dir = Path.home() / ".config/systemd/user"
(systemd_dir / "rewind-daemon.service").unlink(missing_ok=True)
(systemd_dir / "obs.service").unlink(missing_ok=True)
self._run(["systemctl", "--user", "daemon-reload"])
def start_daemon(self):
self._create_systemd_service()
self._run(["systemctl", "--user", "start", "rewind-daemon.service"])
def stop_daemon(self):
self._create_systemd_service()
self._run(["systemctl", "--user", "stop", "rewind-daemon.service"])
self._run(["systemctl", "--user", "stop", "obs.service"])

37
rewind/os/main.py Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
import platform
import shutil
from rewind.os.linux import LinuxBackend
APP_NAME = "rewind-daemon"
def _find_executable():
path = shutil.which(APP_NAME)
if not path:
raise RuntimeError(f"{APP_NAME} not found in PATH")
return path
def _get_backend():
system = platform.system()
program_path = _find_executable()
if system == "Linux":
return LinuxBackend(program_path)
else:
raise RuntimeError(f"Autostart on OS {system} not compatible or not implemented")
def enable_autostart():
backend = _get_backend()
backend.enable_autostart()
def disable_autostart():
backend = _get_backend()
backend.disable_autostart()
def start_daemon():
backend = _get_backend()
backend.start_daemon()
def stop_daemon():
backend = _get_backend()
backend.stop_daemon()

View File

@@ -1,28 +1,65 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import tomllib import platform
from pathlib import Path from pathlib import Path
from importlib import resources
APP_NAME = "rewind" APP_NAME = "rewind"
CONFIG_NAME = "config.toml"
STATE_NAME = "state.json"
def _create_dirs(path: Path) -> None:
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
"""
Returns the path to the directory where config files should be stored, creating it if it doesn't exist.
The location is determined based on the operating system:
- Linux: Uses XDG_CONFIG_HOME environment variable or defaults to ~/.config/rewind
- macOS: Uses ~/Library/Application Support/rewind
- Windows: Uses APPDATA environment variable or defaults to %APPDATA%\rewind
"""
def get_config_dir() -> Path: def get_config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") system = platform.system()
return Path(base) / APP_NAME home = Path.home()
def load_config() -> dict: if system == "Linux":
config_dir = get_config_dir() base = os.environ.get("XDG_CONFIG_HOME", home / ".config")
config_file = config_dir / CONFIG_NAME path = Path(base) / APP_NAME
_create_dirs(path)
return path
elif system == "Darwin":
path = home / "Library" / "Application Support" / APP_NAME
_create_dirs(path)
return path
elif system == "Windows":
path = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) / APP_NAME
_create_dirs(path)
return path
else:
raise OSError("Unsupported operating system")
# Create config directory if it doesn't exist and replace with default config """
if not config_file.exists(): Returns the path to the directory where state files should be stored, creating it if it doesn't exist.
config_dir.mkdir(parents=True, exist_ok=True) The location is determined based on the operating system:
with resources.open_text("rewind", CONFIG_NAME) as default_config: - Linux: Uses XDG_STATE_HOME environment variable or defaults to ~/.local/state/rewind
with config_file.open("w") as f: - macOS: Uses ~/Library/Application Support/rewind/state
f.write(default_config.read()) - Windows: Uses LOCALAPPDATA environment variable or defaults to %LOCALAPPDATA%\rewind\state
"""
def get_state_dir() -> Path:
system = platform.system()
home = Path.home()
with config_file.open("rb") as f: if system == "Linux":
return tomllib.load(f) base = os.environ.get("XDG_STATE_HOME", home / ".local" / "state")
path = Path(base) / APP_NAME
_create_dirs(path)
return path
elif system == "Darwin":
path = home / "Library" / "Application Support" / APP_NAME / "state"
_create_dirs(path)
return path
elif system == "Windows":
base = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
path = base / APP_NAME / "state"
_create_dirs(path)
return path
else:
raise OSError("Unsupported operating system")

View File

@@ -2,28 +2,34 @@
import datetime import datetime
import json import json
import os import os
import logging
from pathlib import Path from pathlib import Path
from rewind.paths import get_state_dir
logger = logging.getLogger(__name__)
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 {"files": [], "markers": []}
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)
@@ -31,25 +37,80 @@ def write_state(state: dict) -> None:
def add_file_to_state(file_path: str) -> None: def add_file_to_state(file_path: str) -> None:
state = load_state() state = load_state()
files = state.get("files", []) files = sorted(state.get("files", []), key=lambda f: f["timestamp"])
# 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()
}) })
state["files"] = files state["files"] = files
write_state(state) write_state(state)
def cleanup_state_files() -> None: logger.info(f"Added new file to state: {file_path}")
state = load_state()
files = state.get("files", []) def add_marker_to_state(marker_name: str) -> None:
state = load_state()
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)
logger.info(f"Added new marker to state: {marker_name}")
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)
logger.info(f"Removed marker from state: {marker_name}")
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)