Compare commits

...

21 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
13 changed files with 512 additions and 215 deletions

View File

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

View File

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

View File

@@ -3,14 +3,16 @@ import sys
import argparse
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:
clip_parser = subparsers.add_parser("clip", help="Clips the last 'x' seconds")
clip_parser.add_argument(
"-s", "--seconds",
type=int,
default=30,
help="Number of seconds to include in the clip (default: 30)"
default=60,
help="Number of seconds to include in the clip (default: 60)"
)
def build_save_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -34,13 +36,33 @@ def build_mark_parser(subparsers: argparse._SubParsersAction) -> None:
mark_parser.add_argument(
"-n", "--name",
type=str,
required=True,
required=False,
help="Name of the marker"
)
def build_list_parser(subparsers: argparse._SubParsersAction) -> None:
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:
parser = argparse.ArgumentParser(
prog="rewind",
@@ -53,12 +75,15 @@ def build_parser() -> argparse.ArgumentParser:
build_mark_parser(sub)
build_list_parser(sub)
build_save_parser(sub)
build_daemon_parser(sub)
build_config_parser(sub)
return parser
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
cfg = Config()
try:
if args.command == "clip":
@@ -69,6 +94,24 @@ def main(argv=None) -> int:
print_markers()
elif args.command == "save":
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:
parser.error("Unknown command")
except Exception as e:
@@ -78,4 +121,4 @@ def main(argv=None) -> int:
return 0
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,14 +1,18 @@
#!/usr/bin/env python3
import os
import datetime
import subprocess
from rewind.config import Config
from rewind.media import get_ts_files, concat_ts_files
from rewind.state import load_state, add_marker_to_state, remove_marker_from_state
from rewind.paths import load_config
from tqdm import tqdm
config = Config()
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)
output_file_name = f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.mp4"
@@ -18,38 +22,43 @@ def clip(seconds_from_end: float) -> None:
end_timestamp = datetime.datetime.now().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,
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}")
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)
first_timestamp = get_marker_timestamp(first_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:
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,
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}")
def mark(name: str) -> None:
if not name:
raise ValueError("Marker name cannot be empty")
name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
add_marker_to_state(name)
print(f"Added marker: {name}")
@@ -84,105 +93,4 @@ def marker_exists(name: str) -> bool:
if marker["name"] == name:
return True
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)
return False

View File

@@ -3,28 +3,24 @@ import os
import datetime
import time
import obsws_python as obs
import subprocess
import logging
import shutil
import signal
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from rewind.paths import load_config
from rewind.paths import get_state_dir
from rewind.config import Config
from rewind.core import mark, marker_exists, remove_marker
from rewind.state import add_file_to_state, create_state_file_if_needed, cleanup_state
running = True
LOG_FILE = get_state_dir() / "daemon.log"
INTERVAL = 10
OBS_MAX_RETRIES = 5
logger = logging.getLogger(__name__)
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)
INTERVAL = 10
SENTINEL_FILE = os.path.expanduser("~/.config/obs-studio/.sentinel")
OBS_MAX_RETRIES = 10
def shutdown(signum, frame):
global running
logger.info(f"Received signal {signum}, shutting down cleanly")
@@ -33,40 +29,26 @@ def shutdown(signum, frame):
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
def clean_obs_sentinel_files():
if os.path.exists(SENTINEL_FILE):
try:
shutil.rmtree(SENTINEL_FILE)
logger.info("Removed existing OBS .sentinel directory")
except Exception as 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
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
def open_obs_connection(host: str, port: int, password: str, obs_max_retries: int) -> dict[str, obs.ReqClient | obs.EventClient]:
req = None
event = None
init_sleep = 1
for _ in range(OBS_MAX_RETRIES):
for _ in range(obs_max_retries):
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:
logger.info("OBS WebSocket not ready, retrying...")
except obs.events.OBSSDKError:
raise RuntimeError("Check OBS credentials")
if con:
if req and event:
logger.info(f"Successfully connected to OBS at {host}:{port}")
return con
return {
"req": req,
"event": event
}
time.sleep(init_sleep)
init_sleep *= 2
@@ -97,50 +79,37 @@ def cleanup_physical_files(directory: str, max_age_seconds: int) -> None:
os.remove(file_path)
logger.info(f"Removed old file: {file_path}")
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 on_record_file_changed(event) -> None:
new_file = event.new_output_path
add_file_to_state(new_file)
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()
config = load_config()
con = open_obs_connection(config["obs"]["host"], config["obs"]["port"], config["obs"]["password"])
config = Config()
obs_host = config.get_obs_host()
obs_port = config.get_obs_port()
obs_password = config.get_obs_password()
recording_dir = con.get_record_directory().record_directory
start_recording(con)
connections = open_obs_connection(obs_host, obs_port, obs_password, OBS_MAX_RETRIES)
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_initial_marker()
try:
event_handler = Handler()
observer = Observer()
observer.schedule(event_handler, path=recording_dir, recursive=False)
observer.start()
while running:
cleanup_physical_files(recording_dir, config["record"]["max_record_time"])
cleanup_state(config["record"]["max_record_time"])
cleanup_physical_files(recording_dir, config.get_max_record_time())
cleanup_state(config.get_max_record_time())
time.sleep(INTERVAL)
finally:
if observer:
observer.stop()
observer.join()
stop_recording(con)
con.disconnect()
stop_recording(req_con)
event_con.disconnect()
req_con.disconnect()
logger.info("Daemon stopped")
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
[obs]
host = "localhost"
port = 4455
password = "6BQWrpHLbyJxNLPJ"
password = ""
[record]
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,32 +1,65 @@
#!/usr/bin/env python3
import os
import tomllib
import platform
from pathlib import Path
from importlib import resources
APP_NAME = "rewind"
CONFIG_NAME = "config.toml"
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:
base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
return Path(base) / APP_NAME
system = platform.system()
home = Path.home()
def load_config() -> dict:
config_dir = get_config_dir()
config_file = config_dir / CONFIG_NAME
if system == "Linux":
base = os.environ.get("XDG_CONFIG_HOME", home / ".config")
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():
config_dir.mkdir(parents=True, exist_ok=True)
with resources.open_text("rewind", CONFIG_NAME) as default_config:
with config_file.open("w") as f:
f.write(default_config.read())
with config_file.open("rb") as f:
return tomllib.load(f)
"""
Returns the path to the directory where state files should be stored, creating it if it doesn't exist.
The location is determined based on the operating system:
- Linux: Uses XDG_STATE_HOME environment variable or defaults to ~/.local/state/rewind
- macOS: Uses ~/Library/Application Support/rewind/state
- Windows: Uses LOCALAPPDATA environment variable or defaults to %LOCALAPPDATA%\rewind\state
"""
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
system = platform.system()
home = Path.home()
if system == "Linux":
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,10 +2,13 @@
import datetime
import json
import os
import logging
from pathlib import Path
from rewind.paths import get_state_dir
logger = logging.getLogger(__name__)
STATE_NAME = "state.json"
EMPTY_STATE = {
"files": [],
@@ -18,7 +21,7 @@ def get_state_file_path() -> Path:
def load_state() -> dict:
path = get_state_file_path()
if not path.exists():
return EMPTY_STATE.copy()
return {"files": [], "markers": []}
with path.open() as f:
return json.load(f)
@@ -34,7 +37,7 @@ def write_state(state: dict) -> None:
def add_file_to_state(file_path: str) -> None:
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}
@@ -43,12 +46,14 @@ def add_file_to_state(file_path: str) -> None:
files.append({
"path": file_path,
"timestamp": datetime.datetime.now().timestamp(),
"timestamp": datetime.datetime.now().timestamp()
})
state["files"] = files
write_state(state)
logger.info(f"Added new file to state: {file_path}")
def add_marker_to_state(marker_name: str) -> None:
state = load_state()
markers = state.get("markers", [])
@@ -65,6 +70,8 @@ def add_marker_to_state(marker_name: str) -> None:
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", [])
@@ -73,6 +80,8 @@ def remove_marker_from_state(marker_name: str) -> None:
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()