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.
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)}