Compare commits

..

11 Commits

13 changed files with 388 additions and 280 deletions

View File

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

View File

@@ -1,5 +1,4 @@
obsws-python==1.8.0 obsws-python==1.8.0
tomli-w==1.1.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

@@ -1,81 +0,0 @@
#!/usr/bin/env python3
import os
import platform
import shutil
import subprocess
from pathlib import Path
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 _run(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 _install_linux(program_path: str):
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={program_path}
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=default.target
""")
_run(["systemctl", "--user", "daemon-reload"])
_run(["systemctl", "--user", "enable", "obs.service"])
_run(["systemctl", "--user", "enable", "rewind-daemon.service"])
_run(["systemctl", "--user", "start", "obs.service"])
_run(["systemctl", "--user", "start", "rewind-daemon.service"])
print("Installed and started OBS + rewind-daemon via systemd")
def install():
system = platform.system()
program_path = _find_executable()
if system == "Linux":
_install_linux(program_path)
else:
raise RuntimeError(f"Autostart on OS {system} not compatible or not implemented")
if __name__ == "__main__":
install()

View File

@@ -3,7 +3,7 @@ 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.autostart import install from rewind.os.main import enable_autostart, disable_autostart, start_daemon, stop_daemon
from rewind.config import Config from rewind.config import Config
def build_clip_parser(subparsers: argparse._SubParsersAction) -> None: def build_clip_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -36,19 +36,21 @@ 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_install_parser(subparsers: argparse._SubParsersAction) -> None: def build_daemon_parser(subparsers: argparse._SubParsersAction) -> None:
subparsers.add_parser( daemon_parser = subparsers.add_parser("daemon", help="Control the rewind daemon")
"install", daemon_sub = daemon_parser.add_subparsers(dest="daemon_command")
help="Enable background recording daemon", daemon_sub.add_parser("start", help="Start the daemon")
description="Install and enable the rewind daemon using system autostart." 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: def build_config_parser(subparsers: argparse._SubParsersAction) -> None:
config_parser = subparsers.add_parser("config", help="View or edit config values") config_parser = subparsers.add_parser("config", help="View or edit config values")
@@ -73,7 +75,7 @@ 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_install_parser(sub) build_daemon_parser(sub)
build_config_parser(sub) build_config_parser(sub)
return parser return parser
@@ -92,15 +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 == "install": elif args.command == "daemon":
install() 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": elif args.command == "config":
if args.config_command == "set": if args.config_command == "set":
cfg.set(args.key, args.value) cfg.set(args.key, args.value)
elif args.config_command == "get": elif args.config_command == "get":
print(cfg.get(args.key)) print(cfg.get(args.key))
else: else:
cfg.show() 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:

View File

@@ -16,6 +16,7 @@ class Config:
self._config_file = self._config_dir / _CONFIG_NAME self._config_file = self._config_dir / _CONFIG_NAME
self._data = self._load() self._data = self._load()
# Private
def _load(self) -> dict: def _load(self) -> dict:
if not self._config_file.exists(): if not self._config_file.exists():
self._config_dir.mkdir(parents=True, exist_ok=True) self._config_dir.mkdir(parents=True, exist_ok=True)
@@ -28,57 +29,46 @@ class Config:
with self._config_file.open("wb") as f: with self._config_file.open("wb") as f:
tomli_w.dump(self._data, f) tomli_w.dump(self._data, f)
@property # Public
def obs_host(self) -> str: def get_obs_host(self) -> str:
return self._data["obs"]["host"] return self._data["obs"]["host"]
@obs_host.setter def set_obs_host(self, value: str) -> None:
def obs_host(self, value: str) -> None:
self._data["obs"]["host"] = value self._data["obs"]["host"] = value
self._save() self._save()
@property def get_obs_port(self) -> int:
def obs_port(self) -> int:
return self._data["obs"]["port"] return self._data["obs"]["port"]
@obs_port.setter def set_obs_port(self, value: int) -> None:
def obs_port(self, value: int) -> None:
self._data["obs"]["port"] = value self._data["obs"]["port"] = value
self._save() self._save()
@property def get_obs_password(self) -> str:
def obs_password(self) -> str:
return self._data["obs"]["password"] return self._data["obs"]["password"]
@obs_password.setter def set_obs_password(self, value: str) -> None:
def obs_password(self, value: str) -> None:
self._data["obs"]["password"] = value self._data["obs"]["password"] = value
self._save() self._save()
@property def get_max_record_time(self) -> int:
def max_record_time(self) -> int:
return self._data["record"]["max_record_time"] return self._data["record"]["max_record_time"]
@max_record_time.setter def set_max_record_time(self, value: int) -> None:
def max_record_time(self, value: int) -> None:
self._data["record"]["max_record_time"] = value self._data["record"]["max_record_time"] = value
self._save() self._save()
@property def get_clip_output(self) -> str:
def clip_output(self) -> str:
return os.path.expanduser(self._data["record"]["clip_output"]) return os.path.expanduser(self._data["record"]["clip_output"])
@clip_output.setter def set_clip_output(self, value: str) -> None:
def clip_output(self, value: str) -> None:
self._data["record"]["clip_output"] = value self._data["record"]["clip_output"] = value
self._save() self._save()
@property def get_vod_output(self) -> str:
def vod_output(self) -> str:
return os.path.expanduser(self._data["record"]["vod_output"]) return os.path.expanduser(self._data["record"]["vod_output"])
@vod_output.setter def set_vod_output(self, value: str) -> None:
def vod_output(self, value: str) -> None:
self._data["record"]["vod_output"] = value self._data["record"]["vod_output"] = value
self._save() self._save()
@@ -97,8 +87,3 @@ class Config:
raise KeyError(f"Unknown config key: {key!r}") raise KeyError(f"Unknown config key: {key!r}")
self._data[section][field] = type(current)(value) self._data[section][field] = type(current)(value)
self._save() self._save()
def show(self) -> None:
for section, values in self._data.items():
for field, value in values.items():
print(f"{section}.{field} = {value!r}")

View File

@@ -1,18 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import datetime import datetime
import subprocess
import tempfile
from rewind.state import load_state, add_marker_to_state, remove_marker_from_state
from rewind.config import Config from rewind.config import Config
from tqdm import tqdm from rewind.media import get_ts_files, concat_ts_files
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:
if seconds_from_end <= 0 or seconds_from_end > 600: 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") raise ValueError("Clip length must be positive and less than or equal to 10 minutes")
clip_output = Config().clip_output 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"
@@ -22,16 +22,18 @@ 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 = Config().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)
@@ -43,17 +45,20 @@ def save(first_marker: str, second_marker: str):
output_file_name = f"{datetime.datetime.fromtimestamp(first_timestamp).strftime('%Y-%m-%d_%H:%M:%S')}-[{first_marker}-{second_marker}].mp4" 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) output_path = os.path.join(vod_dir, output_file_name)
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,
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')
add_marker_to_state(name) add_marker_to_state(name)
print(f"Added marker: {name}") print(f"Added marker: {name}")
@@ -89,106 +94,3 @@ def marker_exists(name: str) -> bool:
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 = [f for f in load_state()["files"] if os.path.exists(f["path"])]
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:
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())

View File

@@ -6,8 +6,6 @@ import obsws_python as obs
import logging import logging
import signal import signal
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from rewind.paths import get_state_dir from rewind.paths import get_state_dir
from rewind.config import Config from rewind.config import Config
from rewind.core import mark, marker_exists, remove_marker from rewind.core import mark, marker_exists, remove_marker
@@ -16,7 +14,6 @@ from rewind.state import add_file_to_state, create_state_file_if_needed, cleanup
running = True running = True
LOG_FILE = get_state_dir() / "daemon.log" LOG_FILE = get_state_dir() / "daemon.log"
INTERVAL = 10 INTERVAL = 10
SENTINEL_FILE = os.path.expanduser("~/.config/obs-studio/.sentinel")
OBS_MAX_RETRIES = 5 OBS_MAX_RETRIES = 5
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,21 +29,26 @@ def shutdown(signum, frame):
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown) signal.signal(signal.SIGTERM, shutdown)
def open_obs_connection(host: str, port: int, password: str, obs_max_retries: int) -> obs.ReqClient: def open_obs_connection(host: str, port: int, password: str, obs_max_retries: int) -> dict[str, obs.ReqClient | obs.EventClient]:
con = None req = None
event = 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: except obs.events.OBSSDKError:
raise RuntimeError("Check OBS credentials") raise RuntimeError("Check OBS credentials")
if con: 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
@@ -77,42 +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}")
class Handler(FileSystemEventHandler): def on_record_file_changed(event) -> None:
def on_created(self, event): new_file = event.new_output_path
if event.is_directory: add_file_to_state(new_file)
return
if not event.src_path.endswith(".ts"):
return
add_file_to_state(event.src_path)
def main() -> None: def main() -> None:
config = Config() config = Config()
con = open_obs_connection(config.obs_host, config.obs_port, config.obs_password, OBS_MAX_RETRIES) 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 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.max_record_time) cleanup_physical_files(recording_dir, config.get_max_record_time())
cleanup_state(config.max_record_time) cleanup_state(config.get_max_record_time())
time.sleep(INTERVAL) time.sleep(INTERVAL)
finally: finally:
if observer: stop_recording(req_con)
observer.stop() event_con.disconnect()
observer.join() req_con.disconnect()
stop_recording(con)
con.disconnect()
logger.info("Daemon stopped") logger.info("Daemon stopped")
if __name__ == "__main__": if __name__ == "__main__":

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,15 +1,65 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import platform
from pathlib import Path from pathlib import Path
APP_NAME = "rewind" APP_NAME = "rewind"
def get_config_dir() -> Path: def _create_dirs(path: Path) -> None:
base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") if not path.exists():
return Path(base) / APP_NAME 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:
system = platform.system()
home = Path.home()
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")
"""
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: def get_state_dir() -> Path:
base = os.path.expanduser("~/.local/share") system = platform.system()
path = Path(base) / APP_NAME home = Path.home()
path.mkdir(parents=True, exist_ok=True)
return path 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

@@ -37,7 +37,7 @@ 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 # Idempotency guard
existing_paths = {f["path"] for f in files} existing_paths = {f["path"] for f in files}
@@ -46,7 +46,7 @@ def add_file_to_state(file_path: str) -> None:
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