Compare commits
31 Commits
d6867c0df3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 87d1491f48 | |||
| d722935020 | |||
| 96f20a714c | |||
| 7b6780bd86 | |||
| 8cd766fc8d | |||
| 441dc02080 | |||
| 31e236dc46 | |||
| 3ef9955015 | |||
| 695320d30f | |||
| 2516b9b299 | |||
| 4121c99bb2 | |||
| b5dbccef85 | |||
| 2ef48609d9 | |||
| d97f15233b | |||
| e32f214a03 | |||
| bf29693e5a | |||
| ef83b7a181 | |||
| 974251d1db | |||
| b3c38c18e0 | |||
| 0af780a95c | |||
| 8b3fe98423 | |||
| c58addff26 | |||
| 81a7442d7a | |||
| b7bf04638a | |||
| 3b21a5276e | |||
| 8ad9917754 | |||
| 2bc838fa6d | |||
| 94758cff4e | |||
|
|
a0040b0f43 | ||
| a697ed6643 | |||
| f40714c409 |
@@ -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
|
||||
3) Setup config.toml with host, port, password info
|
||||
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.
|
||||
|
||||
@@ -7,7 +7,7 @@ requires-python = ">=3.9"
|
||||
|
||||
dependencies = [
|
||||
"obsws-python",
|
||||
"watchdog",
|
||||
"tomli-w",
|
||||
"tqdm"
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
89
rewind/config.py
Normal file
89
rewind/config.py
Normal 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()
|
||||
195
rewind/core.py
195
rewind/core.py
@@ -1,15 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import datetime
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
from rewind.state import load_state
|
||||
from rewind.paths import load_config
|
||||
from tqdm import tqdm
|
||||
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
|
||||
|
||||
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"
|
||||
@@ -19,67 +22,49 @@ 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")
|
||||
|
||||
if marker_exists(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)
|
||||
name = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
add_marker_to_state(name)
|
||||
print(f"Added marker: {name}")
|
||||
|
||||
def get_marker_timestamp(name: str) -> float:
|
||||
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
|
||||
if not os.path.exists(markers_file):
|
||||
raise RuntimeError("No markers found")
|
||||
|
||||
with open(markers_file, "r") as f:
|
||||
markers = json.load(f)
|
||||
markers = load_state().get("markers", [])
|
||||
|
||||
for marker in markers:
|
||||
if marker["name"] == name:
|
||||
@@ -88,144 +73,24 @@ def get_marker_timestamp(name: str) -> float:
|
||||
raise ValueError("Marker name does not exist")
|
||||
|
||||
def print_markers() -> None:
|
||||
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
|
||||
if not os.path.exists(markers_file):
|
||||
print("No markers found.")
|
||||
return
|
||||
markers = load_state().get("markers", [])
|
||||
|
||||
with open(markers_file, "r") as f:
|
||||
markers = json.load(f)
|
||||
if markers == []:
|
||||
print("No markers exist.")
|
||||
|
||||
for marker in markers:
|
||||
format_time = datetime.datetime.fromtimestamp(marker['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
|
||||
print(f"{format_time} -> {marker['name']}")
|
||||
|
||||
def remove_marker(name: str) -> None:
|
||||
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
|
||||
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)
|
||||
|
||||
remove_marker_from_state(name)
|
||||
print(f"Removed marker: {name}")
|
||||
|
||||
def marker_exists(name: str) -> bool:
|
||||
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
|
||||
if not os.path.exists(markers_file):
|
||||
return False
|
||||
|
||||
with open(markers_file, "r") as f:
|
||||
markers = json.load(f)
|
||||
markers = load_state().get("markers", [])
|
||||
|
||||
for marker in markers:
|
||||
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)
|
||||
119
rewind/daemon.py
119
rewind/daemon.py
@@ -3,56 +3,52 @@ import os
|
||||
import datetime
|
||||
import time
|
||||
import obsws_python as obs
|
||||
import subprocess
|
||||
import logging
|
||||
import json
|
||||
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_files
|
||||
from rewind.state import add_file_to_state, create_state_file_if_needed, cleanup_state
|
||||
|
||||
INTERVAL = 10
|
||||
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)
|
||||
|
||||
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")
|
||||
running = False
|
||||
|
||||
def open_obs():
|
||||
kill_command = subprocess.run(['pkill', 'obs'])
|
||||
if kill_command.returncode not in [0, 1]:
|
||||
raise SystemError("Could not kill existing OBS instance")
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
|
||||
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}")
|
||||
|
||||
# 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
|
||||
@@ -83,58 +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}")
|
||||
|
||||
def cleanup_markers(max_age_seconds: float) -> None:
|
||||
markers_file = os.path.join(os.path.dirname(__file__), "markers.json")
|
||||
if not os.path.exists(markers_file):
|
||||
return
|
||||
|
||||
with open(markers_file, "r") as f:
|
||||
markers = json.load(f)
|
||||
|
||||
current_time = datetime.datetime.now().timestamp()
|
||||
new_markers = [m for m in markers if current_time - m['timestamp'] <= max_age_seconds]
|
||||
|
||||
with open(markers_file, "w") as f:
|
||||
json.dump(new_markers, f, indent=4)
|
||||
|
||||
if new_markers != markers:
|
||||
logger.info(f"Cleaning up {len(markers)-len(new_markers)} markers")
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
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:
|
||||
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_files()
|
||||
cleanup_markers(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:
|
||||
stop_recording(con)
|
||||
con.disconnect()
|
||||
stop_recording(req_con)
|
||||
event_con.disconnect()
|
||||
req_con.disconnect()
|
||||
logger.info("Daemon stopped")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[obs]
|
||||
host = "localhost"
|
||||
port = 4455
|
||||
password = "6BQWrpHLbyJxNLPJ"
|
||||
password = ""
|
||||
|
||||
[record]
|
||||
max_record_time = 28800
|
||||
|
||||
114
rewind/media.py
Normal file
114
rewind/media.py
Normal 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
15
rewind/os/backend.py
Normal 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
80
rewind/os/linux.py
Normal 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
37
rewind/os/main.py
Normal 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()
|
||||
@@ -1,28 +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"
|
||||
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:
|
||||
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:
|
||||
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")
|
||||
103
rewind/state.py
103
rewind/state.py
@@ -2,28 +2,34 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from rewind.paths import get_state_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_NAME = "rewind"
|
||||
STATE_NAME = "state.json"
|
||||
|
||||
def state_dir() -> Path:
|
||||
base = os.path.expanduser("~/.local/share")
|
||||
path = Path(base) / APP_NAME
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
EMPTY_STATE = {
|
||||
"files": [],
|
||||
"markers": []
|
||||
}
|
||||
|
||||
def get_state_file_path() -> Path:
|
||||
return state_dir() / STATE_NAME
|
||||
return get_state_dir() / STATE_NAME
|
||||
|
||||
def load_state() -> dict:
|
||||
if not get_state_file_path().exists():
|
||||
return {"files": []}
|
||||
with get_state_file_path().open() as f:
|
||||
path = get_state_file_path()
|
||||
if not path.exists():
|
||||
return {"files": [], "markers": []}
|
||||
|
||||
with path.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
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")
|
||||
with tmp.open("w") as f:
|
||||
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:
|
||||
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({
|
||||
"path": file_path,
|
||||
"timestamp": datetime.datetime.now().timestamp(),
|
||||
"timestamp": datetime.datetime.now().timestamp()
|
||||
})
|
||||
|
||||
state["files"] = files
|
||||
write_state(state)
|
||||
|
||||
def cleanup_state_files() -> None:
|
||||
state = load_state()
|
||||
files = state.get("files", [])
|
||||
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", [])
|
||||
|
||||
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)
|
||||
|
||||
def create_state_file_if_needed() -> None:
|
||||
if not os.path.exists(get_state_file_path()):
|
||||
state = {"files": []}
|
||||
write_state(state)
|
||||
if not get_state_file_path().exists():
|
||||
write_state(EMPTY_STATE.copy())
|
||||
|
||||
Reference in New Issue
Block a user