overc-clips 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overc_clips/__init__.py +0 -0
- overc_clips/cli.py +335 -0
- overc_clips/config.py +133 -0
- overc_clips/engine.py +375 -0
- overc_clips/history.py +303 -0
- overc_clips/importer.py +137 -0
- overc_clips/jobs.py +213 -0
- overc_clips/library.py +255 -0
- overc_clips/preflight.py +239 -0
- overc_clips/server.py +593 -0
- overc_clips/web/index.html +1657 -0
- overc_clips-0.1.0.dist-info/METADATA +120 -0
- overc_clips-0.1.0.dist-info/RECORD +17 -0
- overc_clips-0.1.0.dist-info/WHEEL +5 -0
- overc_clips-0.1.0.dist-info/entry_points.txt +2 -0
- overc_clips-0.1.0.dist-info/licenses/LICENSE +29 -0
- overc_clips-0.1.0.dist-info/top_level.txt +1 -0
overc_clips/__init__.py
ADDED
|
File without changes
|
overc_clips/cli.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Command line for overc-clips. The web service calls the same engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import engine, preflight
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version() -> str:
|
|
18
|
+
try:
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
return version("overc-clips")
|
|
21
|
+
except Exception:
|
|
22
|
+
return "0.1.0"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rows_from_csv(path: Path, need_url: bool) -> list[dict]:
|
|
26
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
27
|
+
rows = [r for r in csv.DictReader(fh)]
|
|
28
|
+
out = []
|
|
29
|
+
for r in rows:
|
|
30
|
+
r = { (k or "").strip().lower(): (v or "").strip() for k, v in r.items() }
|
|
31
|
+
if not any(r.values()):
|
|
32
|
+
continue
|
|
33
|
+
if need_url and not r.get("url"):
|
|
34
|
+
print(f"SKIP row with no url: {r}", file=sys.stderr)
|
|
35
|
+
continue
|
|
36
|
+
out.append(r)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_doctor(a) -> int:
|
|
41
|
+
t = preflight.detect()
|
|
42
|
+
if a.json:
|
|
43
|
+
print(json.dumps(t.as_dict(), indent=2))
|
|
44
|
+
else:
|
|
45
|
+
print("overc-clips — tool check\n")
|
|
46
|
+
print(preflight.report(t))
|
|
47
|
+
return 0 if t.can_cut else 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit(plans, a, produced=None):
|
|
51
|
+
if a.json:
|
|
52
|
+
print(json.dumps({"dry_run": a.dry_run,
|
|
53
|
+
"clips": [c.as_dict() for c in plans],
|
|
54
|
+
"produced": [str(p) for p in (produced or [])]}, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_cut(a) -> int:
|
|
58
|
+
t = preflight.detect()
|
|
59
|
+
if not t.can_cut:
|
|
60
|
+
print("ffmpeg not available — run `overc-clips doctor`", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
src = Path(a.input)
|
|
63
|
+
if not src.exists():
|
|
64
|
+
print(f"input not found: {src}", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
specs = []
|
|
68
|
+
if a.range:
|
|
69
|
+
s, e = a.range.split("-", 1)
|
|
70
|
+
specs.append({"name": a.name or f"clip_{s.replace(':','')}{e.replace(':','')}",
|
|
71
|
+
"start": s, "end": e})
|
|
72
|
+
else:
|
|
73
|
+
specs = _rows_from_csv(Path(a.csv), need_url=False)
|
|
74
|
+
|
|
75
|
+
plans, produced = [], []
|
|
76
|
+
for sp in specs:
|
|
77
|
+
try:
|
|
78
|
+
cs = engine.plan_clip(sp.get("name", ""), sp["start"], sp["end"])
|
|
79
|
+
except (engine.ClipError, KeyError) as ex:
|
|
80
|
+
print(f"SKIP {sp.get('name','?')}: {ex}", file=sys.stderr)
|
|
81
|
+
continue
|
|
82
|
+
plans += cs
|
|
83
|
+
if not a.json and not a.quiet:
|
|
84
|
+
print(f"{sp.get('name','?')}: {len(cs)} clip(s)")
|
|
85
|
+
for c in cs:
|
|
86
|
+
if a.dry_run:
|
|
87
|
+
if not a.json and not a.quiet:
|
|
88
|
+
print(f" -> {c.name} ({c.start_hms}, {c.seconds:.0f}s)")
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
p = engine.encode(src, c, Path(a.outdir), t, encoder=a.encoder,
|
|
92
|
+
accurate=a.accurate, copy=a.copy)
|
|
93
|
+
produced.append(p)
|
|
94
|
+
if not a.json and not a.quiet:
|
|
95
|
+
print(f" -> {p}")
|
|
96
|
+
except engine.ClipError as ex:
|
|
97
|
+
print(f" !! {ex}", file=sys.stderr)
|
|
98
|
+
_emit(plans, a, produced)
|
|
99
|
+
if not a.json and not a.quiet:
|
|
100
|
+
print(f"\n{len(plans)} clip(s) {'planned (dry run)' if a.dry_run else f'in {a.outdir}/'}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_grab(a) -> int:
|
|
105
|
+
t = preflight.detect()
|
|
106
|
+
if not a.dry_run and not t.can_grab:
|
|
107
|
+
print("need ffmpeg + yt-dlp — run `overc-clips doctor`", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
specs = ([{"url": a.url, "name": a.name, "start": a.range.split("-")[0],
|
|
111
|
+
"end": a.range.split("-")[1]}] if a.url
|
|
112
|
+
else _rows_from_csv(Path(a.csv), need_url=True))
|
|
113
|
+
|
|
114
|
+
plans, produced = [], []
|
|
115
|
+
for sp in specs:
|
|
116
|
+
try:
|
|
117
|
+
cs = engine.plan_clip(sp.get("name", ""), sp["start"], sp["end"])
|
|
118
|
+
except (engine.ClipError, KeyError) as ex:
|
|
119
|
+
print(f"SKIP {sp.get('name','?')}: {ex}", file=sys.stderr)
|
|
120
|
+
continue
|
|
121
|
+
plans += cs
|
|
122
|
+
if not a.json and not a.quiet:
|
|
123
|
+
print(f"{cs[0].source_name}: {sp['start']}-{sp['end']} -> {len(cs)} clip(s)")
|
|
124
|
+
if a.dry_run:
|
|
125
|
+
continue
|
|
126
|
+
out = Path(a.outdir)
|
|
127
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="overc-clips-"))
|
|
128
|
+
try:
|
|
129
|
+
span = engine.fetch_span(sp["url"], sp["start"], sp["end"],
|
|
130
|
+
tmpdir / "span", t,
|
|
131
|
+
log=lambda m: print(m, file=sys.stderr))
|
|
132
|
+
if len(cs) == 1:
|
|
133
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
dest = out / f"{cs[0].name}.mp4"
|
|
135
|
+
shutil.move(str(span), dest)
|
|
136
|
+
produced.append(dest)
|
|
137
|
+
if not a.json and not a.quiet:
|
|
138
|
+
print(f" -> {dest}")
|
|
139
|
+
else:
|
|
140
|
+
actual = engine.probe_duration(span, t) or cs[0].seconds * len(cs)
|
|
141
|
+
for c in engine.plan_clip(cs[0].source_name, "00:00:00",
|
|
142
|
+
engine.fmt_hms(actual)):
|
|
143
|
+
p = engine.encode(span, c, out, t, encoder=a.encoder,
|
|
144
|
+
accurate=a.accurate, copy=a.copy)
|
|
145
|
+
produced.append(p)
|
|
146
|
+
if not a.json and not a.quiet:
|
|
147
|
+
print(f" -> {p}")
|
|
148
|
+
except engine.ClipError as ex:
|
|
149
|
+
print(f" !! {ex}", file=sys.stderr)
|
|
150
|
+
finally:
|
|
151
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
152
|
+
_emit(plans, a, produced)
|
|
153
|
+
if not a.json and not a.quiet:
|
|
154
|
+
print(f"\n{len(plans)} clip(s) {'planned (dry run)' if a.dry_run else f'in {a.outdir}/'}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_backfill(a) -> int:
|
|
159
|
+
"""Give existing clips their provenance back.
|
|
160
|
+
|
|
161
|
+
Three sources, cheapest first: what the index already holds, what history
|
|
162
|
+
knows about the video id, and what the file says about itself. Clips made
|
|
163
|
+
before any of this existed have none of the three and stay unknown, which
|
|
164
|
+
is honest -- nothing here guesses.
|
|
165
|
+
"""
|
|
166
|
+
from .history import History
|
|
167
|
+
from .library import Library
|
|
168
|
+
|
|
169
|
+
out = Path(a.outdir)
|
|
170
|
+
lib = Library(out)
|
|
171
|
+
hist = History(out / ".sources.json")
|
|
172
|
+
tools = preflight.detect()
|
|
173
|
+
titles = {(i.get("video_id") or i.get("url")): i.get("title", "")
|
|
174
|
+
for i in hist.list() if i.get("title")}
|
|
175
|
+
|
|
176
|
+
fixed = tagged = probed = 0
|
|
177
|
+
for c in lib.list():
|
|
178
|
+
rec = dict(lib._index.get(c["name"], {}))
|
|
179
|
+
before = dict(rec)
|
|
180
|
+
|
|
181
|
+
if not rec.get("title"):
|
|
182
|
+
key = rec.get("video_id") or rec.get("url") or ""
|
|
183
|
+
if key and titles.get(key):
|
|
184
|
+
rec["title"] = titles[key]
|
|
185
|
+
|
|
186
|
+
# Nothing known at all? Ask the file.
|
|
187
|
+
if not rec.get("title"):
|
|
188
|
+
got = engine.provenance_from_tags(
|
|
189
|
+
engine.read_metadata(out / c["name"], tools))
|
|
190
|
+
probed += 1
|
|
191
|
+
for k, v in got.items():
|
|
192
|
+
rec.setdefault(k, v)
|
|
193
|
+
|
|
194
|
+
if rec and rec != before:
|
|
195
|
+
lib.record(c["name"], **{k: v for k, v in rec.items() if k != "created"})
|
|
196
|
+
fixed += 1
|
|
197
|
+
print(f" {c['name']}\n title -> {rec.get('title','') or '(still unknown)'}")
|
|
198
|
+
|
|
199
|
+
if a.tag_files and rec.get("title"):
|
|
200
|
+
meta = engine.clip_metadata(rec.get("title", ""),
|
|
201
|
+
rec.get("url") or rec.get("path", ""),
|
|
202
|
+
rec.get("start", ""), rec.get("end", ""),
|
|
203
|
+
Path(c["name"]).stem)
|
|
204
|
+
if stamp(out / c["name"], meta, tools):
|
|
205
|
+
tagged += 1
|
|
206
|
+
|
|
207
|
+
print(f"\n{fixed} record(s) updated, {probed} file(s) probed"
|
|
208
|
+
+ (f", {tagged} file(s) tagged" if a.tag_files else ""))
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def stamp(path: Path, meta: dict, tools) -> bool:
|
|
213
|
+
"""Write tags into an existing clip without re-encoding it."""
|
|
214
|
+
import subprocess
|
|
215
|
+
tmp = path.with_suffix(".tagging.mp4")
|
|
216
|
+
r = subprocess.run([str(tools.ffmpeg), "-nostdin", "-hide_banner",
|
|
217
|
+
"-loglevel", "error", "-y", "-i", str(path), "-c", "copy",
|
|
218
|
+
*engine._meta_args(meta), "-movflags", "+faststart", str(tmp)],
|
|
219
|
+
capture_output=True, text=True)
|
|
220
|
+
if r.returncode != 0 or not tmp.exists():
|
|
221
|
+
tmp.unlink(missing_ok=True)
|
|
222
|
+
return False
|
|
223
|
+
# Keep the original timestamp: mtime is what retention ages on, and
|
|
224
|
+
# re-tagging is not the clip being made again.
|
|
225
|
+
st = path.stat()
|
|
226
|
+
tmp.replace(path)
|
|
227
|
+
os.utime(path, (st.st_atime, st.st_mtime))
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_update(a) -> int:
|
|
232
|
+
print("overc-clips — updating video tools\n")
|
|
233
|
+
r = preflight.update_tools()
|
|
234
|
+
if not r["ok"]:
|
|
235
|
+
print(f" FAILED: {r['error']}")
|
|
236
|
+
return 1
|
|
237
|
+
if r.get("changed"):
|
|
238
|
+
print(f" yt-dlp {r['before']} -> {r['after']}")
|
|
239
|
+
else:
|
|
240
|
+
print(f" yt-dlp {r['after'] or 'installed'} — already current")
|
|
241
|
+
print("\n Done.")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_serve(a) -> int:
|
|
246
|
+
from . import server
|
|
247
|
+
cfg = Config()
|
|
248
|
+
given = set(sys.argv[1:])
|
|
249
|
+
def pick(flag, attr, key):
|
|
250
|
+
return getattr(a, attr) if flag in given else cfg.data.get(key, getattr(a, attr))
|
|
251
|
+
a.port = int(pick("--port", "port", "port"))
|
|
252
|
+
a.host = pick("--host", "host", "host")
|
|
253
|
+
a.token = pick("--token", "token", "token")
|
|
254
|
+
a.keep_days = int(pick("--keep-days", "keep_days", "keep_days"))
|
|
255
|
+
if "--outdir" not in given and cfg.data.get("outdir"):
|
|
256
|
+
a.outdir = cfg.data["outdir"]
|
|
257
|
+
auto = sys.stdout.isatty() if a.open is None else a.open
|
|
258
|
+
server.serve(Path(a.outdir), host=a.host, port=a.port, token=a.token,
|
|
259
|
+
workers=a.workers, open_browser=auto, keep_days=a.keep_days,
|
|
260
|
+
config=cfg)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main(argv=None) -> int:
|
|
265
|
+
ap = argparse.ArgumentParser(prog="overc-clips",
|
|
266
|
+
description="Cut analysis clips from video.")
|
|
267
|
+
ap.add_argument("--version", action="version",
|
|
268
|
+
version=f"overc-clips {_version()}")
|
|
269
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
270
|
+
|
|
271
|
+
d = sub.add_parser("doctor", help="what is installed, what is missing")
|
|
272
|
+
d.add_argument("--json", action="store_true")
|
|
273
|
+
d.set_defaults(fn=cmd_doctor)
|
|
274
|
+
|
|
275
|
+
common = dict(outdir="clips", encoder="sw")
|
|
276
|
+
for name, fn, helptext in (("cut", cmd_cut, "cut a local video"),
|
|
277
|
+
("grab", cmd_grab, "fetch spans from a URL")):
|
|
278
|
+
p = sub.add_parser(name, help=helptext)
|
|
279
|
+
if name == "cut":
|
|
280
|
+
p.add_argument("input")
|
|
281
|
+
g = p.add_mutually_exclusive_group(required=True)
|
|
282
|
+
g.add_argument("--range", help="HH:MM:SS-HH:MM:SS")
|
|
283
|
+
g.add_argument("--csv", help="csv: name,start,end")
|
|
284
|
+
else:
|
|
285
|
+
p.add_argument("url", nargs="?")
|
|
286
|
+
p.add_argument("--range", help="HH:MM:SS-HH:MM:SS")
|
|
287
|
+
p.add_argument("--csv", help="csv: url,name,start,end")
|
|
288
|
+
p.add_argument("--name")
|
|
289
|
+
p.add_argument("--outdir", default=common["outdir"])
|
|
290
|
+
p.add_argument("--encoder", default=common["encoder"], choices=["sw", "hw"])
|
|
291
|
+
p.add_argument("--dry-run", action="store_true")
|
|
292
|
+
p.add_argument("--json", action="store_true")
|
|
293
|
+
p.add_argument("--accurate", action="store_true")
|
|
294
|
+
p.add_argument("--copy", action="store_true")
|
|
295
|
+
p.add_argument("--quiet", action="store_true")
|
|
296
|
+
p.set_defaults(fn=fn)
|
|
297
|
+
|
|
298
|
+
bf = sub.add_parser("backfill",
|
|
299
|
+
help="restore provenance for clips already on disk")
|
|
300
|
+
bf.add_argument("--outdir", default="clips")
|
|
301
|
+
bf.add_argument("--tag-files", action="store_true",
|
|
302
|
+
help="also write the tags into the clips (no re-encode)")
|
|
303
|
+
bf.set_defaults(fn=cmd_backfill)
|
|
304
|
+
|
|
305
|
+
up = sub.add_parser("update",
|
|
306
|
+
help="update yt-dlp (the usual fix when downloads fail)")
|
|
307
|
+
up.set_defaults(fn=cmd_update)
|
|
308
|
+
sv = sub.add_parser("serve", help="run the web UI")
|
|
309
|
+
sv.add_argument("--host", default="0.0.0.0",
|
|
310
|
+
help="0.0.0.0 lets a phone on the same wifi reach it")
|
|
311
|
+
sv.add_argument("--port", type=int, default=8080)
|
|
312
|
+
sv.add_argument("--outdir", default="clips")
|
|
313
|
+
sv.add_argument("--token", default="", help="require ?token=... (use when hosted)")
|
|
314
|
+
sv.add_argument("--workers", type=int, default=1)
|
|
315
|
+
sv.add_argument("--keep-days", type=int, default=14,
|
|
316
|
+
help="delete clips older than this many days (0 = keep forever)")
|
|
317
|
+
# Opening the browser is the point for someone who was handed a launcher,
|
|
318
|
+
# so it is the default when a person is watching. Redirected output means a
|
|
319
|
+
# service manager is running us, and popping a browser there is wrong.
|
|
320
|
+
sv.add_argument("--open", action="store_true", default=None,
|
|
321
|
+
help="open a browser on start (default when interactive)")
|
|
322
|
+
sv.add_argument("--no-open", dest="open", action="store_false",
|
|
323
|
+
help="never open a browser")
|
|
324
|
+
sv.set_defaults(fn=cmd_serve)
|
|
325
|
+
|
|
326
|
+
a = ap.parse_args(argv)
|
|
327
|
+
if a.cmd == "grab" and not a.url and not a.csv:
|
|
328
|
+
ap.error("grab needs a URL with --range, or --csv")
|
|
329
|
+
if a.cmd == "grab" and a.url and not a.range:
|
|
330
|
+
ap.error("grab with a URL needs --range")
|
|
331
|
+
return a.fn(a)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
sys.exit(main())
|
overc_clips/config.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Saved launch options.
|
|
2
|
+
|
|
3
|
+
A desktop launcher is a fixed command line, so anything you can only set with a
|
|
4
|
+
flag is unreachable to the person who was handed an icon. This keeps their
|
|
5
|
+
choices in a small JSON file that the CLI reads as its defaults.
|
|
6
|
+
|
|
7
|
+
Precedence is flag > file > built-in, so an explicit flag always wins and a
|
|
8
|
+
saved preference never silently overrides what someone typed.
|
|
9
|
+
|
|
10
|
+
Two kinds of setting live here and the difference matters to the UI: retention
|
|
11
|
+
and the default encoder take effect immediately, while host, port and token are
|
|
12
|
+
fixed when the socket is opened and cannot change without a restart. Presenting
|
|
13
|
+
those the same way would promise something the running process cannot deliver.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULTS = {
|
|
24
|
+
"keep_days": 14, # live
|
|
25
|
+
"encoder": "sw", # live
|
|
26
|
+
"clip_limit": 120, # live — seconds; 0 means the platform has no limit
|
|
27
|
+
"recent_limit": 12, # live — how many videos "recent" keeps
|
|
28
|
+
"autosave_points": False, # live — keep a running record of clip points
|
|
29
|
+
"port": 8080, # restart
|
|
30
|
+
"host": "0.0.0.0", # restart
|
|
31
|
+
"token": "", # restart
|
|
32
|
+
"outdir": "", # restart; empty means "decide at launch"
|
|
33
|
+
}
|
|
34
|
+
LIVE_KEYS = ("keep_days", "encoder", "clip_limit", "recent_limit",
|
|
35
|
+
"autosave_points")
|
|
36
|
+
RESTART_KEYS = ("port", "host", "token", "outdir")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_path() -> Path:
|
|
40
|
+
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
|
|
41
|
+
return Path(base) / "overc-clips" / "config.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Config:
|
|
45
|
+
def __init__(self, path: Path | None = None):
|
|
46
|
+
self.path = Path(path or config_path())
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self.data = dict(DEFAULTS)
|
|
49
|
+
self.data.update(self._read())
|
|
50
|
+
|
|
51
|
+
def _read(self) -> dict:
|
|
52
|
+
try:
|
|
53
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return {}
|
|
56
|
+
# Ignore unknown keys rather than carrying them forward: a typo in a
|
|
57
|
+
# hand-edited file should not look like it took effect.
|
|
58
|
+
return {k: v for k, v in raw.items() if k in DEFAULTS}
|
|
59
|
+
|
|
60
|
+
def save(self) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
|
|
64
|
+
try:
|
|
65
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
66
|
+
json.dump(self.data, fh, indent=1)
|
|
67
|
+
os.replace(tmp, self.path)
|
|
68
|
+
except OSError:
|
|
69
|
+
Path(tmp).unlink(missing_ok=True)
|
|
70
|
+
|
|
71
|
+
def update(self, changes: dict) -> tuple[dict, list[str]]:
|
|
72
|
+
"""Apply what is valid, report what was not. -> (applied, problems)"""
|
|
73
|
+
applied, problems = {}, []
|
|
74
|
+
for k, v in (changes or {}).items():
|
|
75
|
+
if k not in DEFAULTS:
|
|
76
|
+
problems.append(f"unknown setting: {k}")
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
if k == "keep_days":
|
|
80
|
+
v = int(v)
|
|
81
|
+
# Clamping a negative to 0 would quietly switch retention
|
|
82
|
+
# off, which is a large behaviour change to infer from a
|
|
83
|
+
# value the user got wrong. 0 has to be asked for.
|
|
84
|
+
if v < 0:
|
|
85
|
+
raise ValueError("must be 0 (never) or a positive number of days")
|
|
86
|
+
elif k == "clip_limit":
|
|
87
|
+
v = int(v)
|
|
88
|
+
if v < 0:
|
|
89
|
+
raise ValueError("must be 0 (no limit) or a number of seconds")
|
|
90
|
+
if 0 < v < 10:
|
|
91
|
+
raise ValueError("a limit under 10s would split everything to nothing")
|
|
92
|
+
elif k == "autosave_points":
|
|
93
|
+
v = bool(v)
|
|
94
|
+
elif k == "recent_limit":
|
|
95
|
+
v = int(v)
|
|
96
|
+
if not 1 <= v <= 200:
|
|
97
|
+
raise ValueError("must be between 1 and 200")
|
|
98
|
+
elif k == "port":
|
|
99
|
+
v = int(v)
|
|
100
|
+
if not 1 <= v <= 65535:
|
|
101
|
+
raise ValueError("port out of range")
|
|
102
|
+
elif k == "encoder":
|
|
103
|
+
v = str(v)
|
|
104
|
+
if v not in ("sw", "hw"):
|
|
105
|
+
raise ValueError("encoder must be sw or hw")
|
|
106
|
+
elif k == "outdir":
|
|
107
|
+
v = str(v).strip().strip('"').strip("'")
|
|
108
|
+
if v:
|
|
109
|
+
from pathlib import Path as _P
|
|
110
|
+
pth = _P(v).expanduser()
|
|
111
|
+
if not pth.is_absolute():
|
|
112
|
+
raise ValueError("use a full path")
|
|
113
|
+
# Saving a path that cannot be created would only fail
|
|
114
|
+
# at the next launch, when the reason is long forgotten.
|
|
115
|
+
try:
|
|
116
|
+
pth.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
except OSError as ex:
|
|
118
|
+
raise ValueError(f"cannot use that folder ({ex.strerror})") from ex
|
|
119
|
+
v = str(pth)
|
|
120
|
+
else:
|
|
121
|
+
v = str(v)
|
|
122
|
+
except (TypeError, ValueError) as ex:
|
|
123
|
+
problems.append(f"{k}: {ex}")
|
|
124
|
+
continue
|
|
125
|
+
self.data[k] = v
|
|
126
|
+
applied[k] = v
|
|
127
|
+
if applied:
|
|
128
|
+
self.save()
|
|
129
|
+
return applied, problems
|
|
130
|
+
|
|
131
|
+
def as_dict(self) -> dict:
|
|
132
|
+
return {"values": dict(self.data), "live": list(LIVE_KEYS),
|
|
133
|
+
"restart": list(RESTART_KEYS), "path": str(self.path)}
|