overc-clips 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Bruce Ashfield
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: overc-clips
3
+ Version: 0.1.0
4
+ Summary: Cut volleyball analysis clips from video, locally or hosted. Web UI, no command line required.
5
+ Author-email: Bruce Ashfield <bruce.ashfield@gmail.com>
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://zedd.org/overc-clips/
8
+ Project-URL: Source, https://gitlab.com/bruce-ashfield/volleyball
9
+ Keywords: video,clips,volleyball,coaching,ffmpeg,yt-dlp
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Multimedia :: Video :: Conversion
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: yt-dlp>=2024.1.1
24
+ Requires-Dist: imageio-ffmpeg>=0.5
25
+ Dynamic: license-file
26
+
27
+ # overc-clips
28
+
29
+ Cut analysis clips out of a YouTube video or a local file, through a web page,
30
+ with no command line required.
31
+
32
+ Built for volleyball coaches handing clips to video-review sites, but there is
33
+ nothing sport-specific in it. Point it at a video, mark the spans you want, and
34
+ it produces the clips.
35
+
36
+ It runs two ways from one codebase:
37
+
38
+ - **On your own machine.** It starts a small web server and prints an address.
39
+ Open that in a browser on the same machine, or on your phone if you are on the
40
+ same wifi. The video processing happens on your computer; the phone is only
41
+ the remote control.
42
+ - **Hosted**, like any other internal web app.
43
+
44
+ ## What you need
45
+
46
+ **Python 3.10 or newer. That is all.** ffmpeg and yt-dlp come with it — the
47
+ install pulls a static ffmpeg build, so there is nothing to install separately
48
+ and nothing to put on your PATH.
49
+
50
+ Works on macOS, Linux and Windows.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install overc-clips
56
+ ```
57
+
58
+ On current macOS and most Linux distributions that will refuse with a message
59
+ about an "externally managed environment" — that is the operating system
60
+ protecting its own Python, not an error in this package. Use a virtualenv:
61
+
62
+ ```bash
63
+ python3 -m venv ~/.local/share/overc-clips/venv
64
+ ~/.local/share/overc-clips/venv/bin/pip install overc-clips
65
+ ~/.local/share/overc-clips/venv/bin/overc-clips serve
66
+ ```
67
+
68
+ or install it with `pipx`, which does the same thing for you:
69
+
70
+ ```bash
71
+ pipx install overc-clips
72
+ ```
73
+
74
+ ## Use it
75
+
76
+ ```bash
77
+ overc-clips serve # start the web UI, print the address to visit
78
+ overc-clips doctor # what it found on this machine, and what is missing
79
+ ```
80
+
81
+ `serve` prints a local address and, if it can, a second one for other devices on
82
+ your network. Everything else happens in the browser: paste a video link, scrub
83
+ to the start and end of each clip, and make them.
84
+
85
+ Useful flags:
86
+
87
+ ```bash
88
+ overc-clips serve --outdir ~/Movies/clips # where clips are written
89
+ overc-clips serve --port 8080 # default
90
+ overc-clips serve --token secret # require a token, for a shared network
91
+ ```
92
+
93
+ Settings you would otherwise need a flag for — the output folder, how long clips
94
+ are kept, the maximum clip length — are in the web UI itself, so someone who was
95
+ handed a desktop shortcut can still change them.
96
+
97
+ ## Clips
98
+
99
+ Clips are named after the source video and their timestamps, so two cuts from
100
+ one match never collide. Where each clip came from is written into the file as
101
+ mp4 metadata, which survives the file being moved or shared, and the built-in
102
+ library can group, search and file them.
103
+
104
+ Clips are deleted after 14 days by default, which the UI tells you on startup
105
+ and lets you change or switch off.
106
+
107
+ ## Keeping it working
108
+
109
+ YouTube changes things regularly and an out-of-date yt-dlp is the usual cause of
110
+ a download that suddenly fails. Updating it is independent of this package:
111
+
112
+ ```bash
113
+ pip install -U yt-dlp # in whichever environment you installed into
114
+ ```
115
+
116
+ `overc-clips doctor` reports the version it is using and where it came from.
117
+
118
+ ## Licence
119
+
120
+ BSD 3-Clause. See `LICENSE`.
@@ -0,0 +1,94 @@
1
+ # overc-clips
2
+
3
+ Cut analysis clips out of a YouTube video or a local file, through a web page,
4
+ with no command line required.
5
+
6
+ Built for volleyball coaches handing clips to video-review sites, but there is
7
+ nothing sport-specific in it. Point it at a video, mark the spans you want, and
8
+ it produces the clips.
9
+
10
+ It runs two ways from one codebase:
11
+
12
+ - **On your own machine.** It starts a small web server and prints an address.
13
+ Open that in a browser on the same machine, or on your phone if you are on the
14
+ same wifi. The video processing happens on your computer; the phone is only
15
+ the remote control.
16
+ - **Hosted**, like any other internal web app.
17
+
18
+ ## What you need
19
+
20
+ **Python 3.10 or newer. That is all.** ffmpeg and yt-dlp come with it — the
21
+ install pulls a static ffmpeg build, so there is nothing to install separately
22
+ and nothing to put on your PATH.
23
+
24
+ Works on macOS, Linux and Windows.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install overc-clips
30
+ ```
31
+
32
+ On current macOS and most Linux distributions that will refuse with a message
33
+ about an "externally managed environment" — that is the operating system
34
+ protecting its own Python, not an error in this package. Use a virtualenv:
35
+
36
+ ```bash
37
+ python3 -m venv ~/.local/share/overc-clips/venv
38
+ ~/.local/share/overc-clips/venv/bin/pip install overc-clips
39
+ ~/.local/share/overc-clips/venv/bin/overc-clips serve
40
+ ```
41
+
42
+ or install it with `pipx`, which does the same thing for you:
43
+
44
+ ```bash
45
+ pipx install overc-clips
46
+ ```
47
+
48
+ ## Use it
49
+
50
+ ```bash
51
+ overc-clips serve # start the web UI, print the address to visit
52
+ overc-clips doctor # what it found on this machine, and what is missing
53
+ ```
54
+
55
+ `serve` prints a local address and, if it can, a second one for other devices on
56
+ your network. Everything else happens in the browser: paste a video link, scrub
57
+ to the start and end of each clip, and make them.
58
+
59
+ Useful flags:
60
+
61
+ ```bash
62
+ overc-clips serve --outdir ~/Movies/clips # where clips are written
63
+ overc-clips serve --port 8080 # default
64
+ overc-clips serve --token secret # require a token, for a shared network
65
+ ```
66
+
67
+ Settings you would otherwise need a flag for — the output folder, how long clips
68
+ are kept, the maximum clip length — are in the web UI itself, so someone who was
69
+ handed a desktop shortcut can still change them.
70
+
71
+ ## Clips
72
+
73
+ Clips are named after the source video and their timestamps, so two cuts from
74
+ one match never collide. Where each clip came from is written into the file as
75
+ mp4 metadata, which survives the file being moved or shared, and the built-in
76
+ library can group, search and file them.
77
+
78
+ Clips are deleted after 14 days by default, which the UI tells you on startup
79
+ and lets you change or switch off.
80
+
81
+ ## Keeping it working
82
+
83
+ YouTube changes things regularly and an out-of-date yt-dlp is the usual cause of
84
+ a download that suddenly fails. Updating it is independent of this package:
85
+
86
+ ```bash
87
+ pip install -U yt-dlp # in whichever environment you installed into
88
+ ```
89
+
90
+ `overc-clips doctor` reports the version it is using and where it came from.
91
+
92
+ ## Licence
93
+
94
+ BSD 3-Clause. See `LICENSE`.
File without changes
@@ -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())