tv-recorder 0.0.0.dev2__tar.gz → 0.0.0.dev3__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.
Files changed (24) hide show
  1. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/PKG-INFO +23 -1
  2. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/README.md +21 -0
  3. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/pyproject.toml +6 -8
  4. tv_recorder-0.0.0.dev3/requirements.txt +5 -0
  5. tv_recorder-0.0.0.dev3/src/tv_recorder/cli.py +262 -0
  6. tv_recorder-0.0.0.dev3/src/tv_recorder/comskip.py +446 -0
  7. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/config.py +2 -0
  8. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/defaults.yaml +20 -0
  9. tv_recorder-0.0.0.dev3/tests/test_cli.py +37 -0
  10. tv_recorder-0.0.0.dev3/tests/test_comskip.py +188 -0
  11. tv_recorder-0.0.0.dev2/src/tv_recorder/cli.py +0 -126
  12. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
  13. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
  14. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
  15. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.github/workflows/publish.yml +0 -0
  16. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.gitignore +0 -0
  17. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/LICENSE +0 -0
  18. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/scripts/check_version.py +0 -0
  19. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/__init__.py +0 -0
  20. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/duration.py +0 -0
  21. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/recorder.py +0 -0
  22. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/stream_finder.py +0 -0
  23. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/tests/test_duration.py +0 -0
  24. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/tests/test_version_consistency.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tv-recorder
3
- Version: 0.0.0.dev2
3
+ Version: 0.0.0.dev3
4
4
  Summary: Command-line recorder for public live TV streams.
5
5
  Project-URL: Homepage, https://github.com/onclefranck/tv-recorder
6
6
  Project-URL: Repository, https://github.com/onclefranck/tv-recorder
@@ -41,6 +41,7 @@ Classifier: Programming Language :: Python :: 3.13
41
41
  Classifier: Topic :: Multimedia :: Video
42
42
  Classifier: Topic :: Utilities
43
43
  Requires-Python: >=3.11
44
+ Requires-Dist: click>=8.1
44
45
  Requires-Dist: imageio-ffmpeg>=0.6.0
45
46
  Requires-Dist: playwright>=1.44
46
47
  Requires-Dist: pytimeparse2>=1.7.1
@@ -92,12 +93,32 @@ tv-recorder globalnews-montreal now 30m
92
93
  tv-recorder radio-canada.ca now 10m --headful
93
94
  tv-recorder radio-canada.ca now 10m --dry-run
94
95
  tv-recorder radio-canada.ca now 10m --debug
96
+ tv-recorder radio-canada.ca now 10m --comskip
97
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
95
98
  ```
96
99
 
97
100
  `START` accepts `now` or a local ISO date. `DURATION` accepts values such as `90s`, `30m`, `2h`, or `01:30:00`.
98
101
 
99
102
  By default, the CLI runs at `--info` level and prints the effective recording URL or inputs. During recording, a small activity indicator moves when ffmpeg emits progress. Use `--debug` to show discovery details, step results, and ffmpeg output.
100
103
 
104
+ ## Commercial Marking
105
+
106
+ Use `--comskip` to run Comskip after a successful recording and create a commercial-free MP4. The original recording is kept unchanged. Comskip writes sidecar files next to it, including an `.edl` file when commercials are detected:
107
+
108
+ ```powershell
109
+ tv-recorder radio-canada.ca now 1h --comskip
110
+ ```
111
+
112
+ Run the same post-processing on an existing recording:
113
+
114
+ ```powershell
115
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
116
+ ```
117
+
118
+ The channel settings are selected from the file name prefix, such as `tvaplus.ca-...mp4`.
119
+
120
+ On Windows, Comskip is downloaded automatically on first use if `comskip.exe` is not already available. It is installed inside the active Python environment under `share\tv-recorder\comskip\`, which keeps a `pipx` installation self-contained.
121
+
101
122
  ## Configuration YAML
102
123
 
103
124
  The package includes a default configuration. To replace it, provide your own YAML file:
@@ -115,6 +136,7 @@ Each source can define:
115
136
  - `stream_response_json_keys`: JSON keys whose values should be treated as stream URLs.
116
137
  - `stream_url_reject_patterns`: stream URL patterns to ignore.
117
138
  - `recording`: video/audio track selection.
139
+ - `comskip`: commercial marking and cutting settings.
118
140
  - `steps`: browser interaction recipe before stream detection.
119
141
  - `output_extension`: output file extension.
120
142
  - `user_agent`: optional user-agent for browser and recorder requests.
@@ -39,12 +39,32 @@ tv-recorder globalnews-montreal now 30m
39
39
  tv-recorder radio-canada.ca now 10m --headful
40
40
  tv-recorder radio-canada.ca now 10m --dry-run
41
41
  tv-recorder radio-canada.ca now 10m --debug
42
+ tv-recorder radio-canada.ca now 10m --comskip
43
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
42
44
  ```
43
45
 
44
46
  `START` accepts `now` or a local ISO date. `DURATION` accepts values such as `90s`, `30m`, `2h`, or `01:30:00`.
45
47
 
46
48
  By default, the CLI runs at `--info` level and prints the effective recording URL or inputs. During recording, a small activity indicator moves when ffmpeg emits progress. Use `--debug` to show discovery details, step results, and ffmpeg output.
47
49
 
50
+ ## Commercial Marking
51
+
52
+ Use `--comskip` to run Comskip after a successful recording and create a commercial-free MP4. The original recording is kept unchanged. Comskip writes sidecar files next to it, including an `.edl` file when commercials are detected:
53
+
54
+ ```powershell
55
+ tv-recorder radio-canada.ca now 1h --comskip
56
+ ```
57
+
58
+ Run the same post-processing on an existing recording:
59
+
60
+ ```powershell
61
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
62
+ ```
63
+
64
+ The channel settings are selected from the file name prefix, such as `tvaplus.ca-...mp4`.
65
+
66
+ On Windows, Comskip is downloaded automatically on first use if `comskip.exe` is not already available. It is installed inside the active Python environment under `share\tv-recorder\comskip\`, which keeps a `pipx` installation self-contained.
67
+
48
68
  ## Configuration YAML
49
69
 
50
70
  The package includes a default configuration. To replace it, provide your own YAML file:
@@ -62,6 +82,7 @@ Each source can define:
62
82
  - `stream_response_json_keys`: JSON keys whose values should be treated as stream URLs.
63
83
  - `stream_url_reject_patterns`: stream URL patterns to ignore.
64
84
  - `recording`: video/audio track selection.
85
+ - `comskip`: commercial marking and cutting settings.
65
86
  - `steps`: browser interaction recipe before stream detection.
66
87
  - `output_extension`: output file extension.
67
88
  - `user_agent`: optional user-agent for browser and recorder requests.
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling>=1.24"]
2
+ requires = ["hatchling>=1.24", "hatch-requirements-txt>=0.4.1"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tv-recorder"
7
- version = "0.0.0.dev2"
7
+ version = "0.0.0.dev3"
8
8
  description = "Command-line recorder for public live TV streams."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -26,12 +26,7 @@ classifiers = [
26
26
  "Topic :: Multimedia :: Video",
27
27
  "Topic :: Utilities",
28
28
  ]
29
- dependencies = [
30
- "imageio-ffmpeg>=0.6.0",
31
- "playwright>=1.44",
32
- "pytimeparse2>=1.7.1",
33
- "PyYAML>=6.0.1",
34
- ]
29
+ dynamic = ["dependencies"]
35
30
 
36
31
  [project.optional-dependencies]
37
32
  dev = [
@@ -54,5 +49,8 @@ packages = ["src/tv_recorder"]
54
49
  [tool.hatch.build.targets.wheel.force-include]
55
50
  "src/tv_recorder/defaults.yaml" = "tv_recorder/defaults.yaml"
56
51
 
52
+ [tool.hatch.metadata.hooks.requirements_txt]
53
+ files = ["requirements.txt"]
54
+
57
55
  [tool.pytest.ini_options]
58
56
  testpaths = ["tests"]
@@ -0,0 +1,5 @@
1
+ click>=8.1
2
+ imageio-ffmpeg>=0.6.0
3
+ playwright>=1.44
4
+ pytimeparse2>=1.7.1
5
+ PyYAML>=6.0.1
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from tv_recorder import recorder
10
+ from tv_recorder.comskip import build_comskip_plan, cut_commercials, run_comskip
11
+ from tv_recorder.config import get_source, load_config
12
+ from tv_recorder.duration import parse_duration, parse_start, seconds_until
13
+ from tv_recorder.recorder import build_ffmpeg_plan, build_output_path, run_recording
14
+ from tv_recorder.stream_finder import find_stream
15
+
16
+
17
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
18
+ @click.argument("source", required=False)
19
+ @click.argument("start", required=False)
20
+ @click.argument("duration", required=False)
21
+ @click.option("--config", "config_path", type=click.Path(path_type=Path), help="Path to a YAML source config file.")
22
+ @click.option("--list", "list_channels", is_flag=True, help="List available sources.")
23
+ @click.option("--output-dir", type=click.Path(path_type=Path), default=Path("recordings"), show_default=True, help="Output directory.")
24
+ @click.option("--headful", is_flag=True, help="Show Chromium while detecting the stream.")
25
+ @click.option("--timeout-ms", type=int, default=45_000, show_default=True, help="Playwright timeout in milliseconds.")
26
+ @click.option("--ffmpeg", "ffmpeg_path", default="ffmpeg", show_default=True, help="Override the bundled imageio-ffmpeg binary.")
27
+ @click.option("--comskip", is_flag=True, help="Run Comskip after recording and create a commercial-free MP4.")
28
+ @click.option("--dry-run", is_flag=True, help="Detect the stream and print the ffmpeg command.")
29
+ @click.option("--info", "log_level", flag_value="info", default="info", help="Show normal progress and selected HLS URLs.")
30
+ @click.option("--debug", "log_level", flag_value="debug", help="Show discovery steps and ffmpeg output.")
31
+ def main(
32
+ source: str | None,
33
+ start: str | None,
34
+ duration: str | None,
35
+ config_path: Path | None,
36
+ list_channels: bool,
37
+ output_dir: Path,
38
+ headful: bool,
39
+ timeout_ms: int,
40
+ ffmpeg_path: str,
41
+ comskip: bool,
42
+ dry_run: bool,
43
+ log_level: str,
44
+ ) -> None:
45
+ """Find an HLS stream and record it."""
46
+ try:
47
+ config = load_config(config_path)
48
+ if list_channels:
49
+ list_sources(config)
50
+ return
51
+
52
+ if source == "comskip":
53
+ if start is None or duration is not None:
54
+ raise click.UsageError("comskip requires exactly one recording file path")
55
+ exit_code = run_existing_comskip(
56
+ config,
57
+ Path(start),
58
+ ffmpeg_path=ffmpeg_path,
59
+ log_level=log_level,
60
+ )
61
+ raise click.exceptions.Exit(exit_code)
62
+
63
+ if source is None or start is None or duration is None:
64
+ raise click.UsageError("source, start, and duration are required unless --list is used")
65
+
66
+ exit_code = run_record_command(
67
+ config,
68
+ source_key=source,
69
+ start_value=start,
70
+ duration_value=duration,
71
+ output_dir=output_dir,
72
+ headful=headful,
73
+ timeout_ms=timeout_ms,
74
+ ffmpeg_path=ffmpeg_path,
75
+ comskip=comskip,
76
+ dry_run=dry_run,
77
+ log_level=log_level,
78
+ )
79
+ raise click.exceptions.Exit(exit_code)
80
+ except click.exceptions.Exit:
81
+ raise
82
+ except click.ClickException:
83
+ raise
84
+ except Exception as exc:
85
+ click.echo(f"Error: {exc}", err=True)
86
+ raise click.exceptions.Exit(1)
87
+
88
+
89
+ def run_record_command(
90
+ config: dict,
91
+ *,
92
+ source_key: str,
93
+ start_value: str,
94
+ duration_value: str,
95
+ output_dir: Path,
96
+ headful: bool,
97
+ timeout_ms: int,
98
+ ffmpeg_path: str,
99
+ comskip: bool,
100
+ dry_run: bool,
101
+ log_level: str,
102
+ ) -> int:
103
+ source = get_source(config, source_key)
104
+ start = parse_start(start_value)
105
+ duration_seconds = parse_duration(duration_value)
106
+ delay = seconds_until(start)
107
+
108
+ if delay > 0:
109
+ _info(log_level, f"Waiting until {start.isoformat()} ({int(delay)} s).")
110
+ time.sleep(delay)
111
+
112
+ _info(log_level, f"Detecting stream for {source.display_name}...")
113
+ stream = find_stream(
114
+ source,
115
+ headless=not headful,
116
+ timeout_ms=timeout_ms,
117
+ debug_log=lambda message: _debug(log_level, message),
118
+ )
119
+ output_path = build_output_path(output_dir, source, start)
120
+ plan = build_ffmpeg_plan(
121
+ stream,
122
+ output_path,
123
+ duration_seconds,
124
+ ffmpeg_path=ffmpeg_path,
125
+ require_ffmpeg=not dry_run,
126
+ recording=source.recording,
127
+ )
128
+ _print_stream_urls(log_level, stream)
129
+
130
+ if dry_run:
131
+ click.echo("ffmpeg command:")
132
+ click.echo(shlex.join(plan.command))
133
+ if comskip:
134
+ comskip_plan = build_comskip_plan(
135
+ plan.output_path,
136
+ require_comskip=False,
137
+ auto_install=False,
138
+ options=source.comskip,
139
+ )
140
+ click.echo("comskip command:")
141
+ click.echo(shlex.join(comskip_plan.command))
142
+ return 0
143
+
144
+ _info(log_level, f"Recording to {plan.output_path}")
145
+ exit_code = run_recording(plan, debug=log_level == "debug")
146
+ if exit_code != 0:
147
+ click.echo(f"ffmpeg exited with code {exit_code}.", err=True)
148
+ return exit_code
149
+
150
+ if comskip:
151
+ return run_comskip_pipeline(
152
+ config,
153
+ plan.output_path,
154
+ ffmpeg_path=plan.ffmpeg_path,
155
+ log_level=log_level,
156
+ )
157
+
158
+ return 0
159
+
160
+
161
+ def list_sources(config: dict) -> None:
162
+ sources = config.get("sources") or {}
163
+ if not sources:
164
+ click.echo("No sources available.")
165
+ return
166
+
167
+ width = max(len(key) for key in sources)
168
+ for key in sorted(sources):
169
+ display_name = sources[key].get("display_name") or key
170
+ click.echo(f"{key.ljust(width)} {display_name}")
171
+
172
+
173
+ def run_existing_comskip(
174
+ config: dict,
175
+ recording_path: Path,
176
+ *,
177
+ ffmpeg_path: str,
178
+ log_level: str,
179
+ ) -> int:
180
+ if not recording_path.exists():
181
+ raise FileNotFoundError(recording_path)
182
+ resolved_ffmpeg = recorder._resolve_ffmpeg(ffmpeg_path, require_ffmpeg=True)
183
+ return run_comskip_pipeline(
184
+ config,
185
+ recording_path,
186
+ ffmpeg_path=resolved_ffmpeg,
187
+ log_level=log_level,
188
+ )
189
+
190
+
191
+ def run_comskip_pipeline(
192
+ config: dict,
193
+ recording_path: Path,
194
+ *,
195
+ ffmpeg_path: str,
196
+ log_level: str,
197
+ ) -> int:
198
+ source_key = _source_key_from_recording(config, recording_path)
199
+ source = get_source(config, source_key) if source_key else None
200
+ if source:
201
+ _info(log_level, f"Using Comskip settings for {source.display_name}.")
202
+ else:
203
+ _info(log_level, "Using default Comskip settings.")
204
+
205
+ comskip_plan = build_comskip_plan(
206
+ recording_path,
207
+ auto_install=True,
208
+ options=source.comskip if source else None,
209
+ log=lambda message: _info(log_level, message),
210
+ )
211
+ _info(log_level, f"Running Comskip on {comskip_plan.recording_path}")
212
+ _debug(log_level, f"Comskip command: {shlex.join(comskip_plan.command)}")
213
+ exit_code = run_comskip(comskip_plan, debug=log_level == "debug")
214
+ if exit_code != 0:
215
+ click.echo(f"comskip exited with code {exit_code}.", err=True)
216
+ return exit_code
217
+
218
+ _info(log_level, f"Comskip EDL: {comskip_plan.edl_path}")
219
+ _info(log_level, f"Writing commercial-free MP4 to {comskip_plan.commercial_free_path}")
220
+ exit_code = cut_commercials(
221
+ comskip_plan,
222
+ ffmpeg_path=ffmpeg_path,
223
+ debug=log_level == "debug",
224
+ )
225
+ if exit_code != 0:
226
+ click.echo(f"commercial cutting exited with code {exit_code}.", err=True)
227
+ return exit_code
228
+ _info(log_level, f"Commercial-free MP4: {comskip_plan.commercial_free_path}")
229
+ return 0
230
+
231
+
232
+ def _source_key_from_recording(config: dict, recording_path: Path) -> str | None:
233
+ sources = config.get("sources") or {}
234
+ name = recording_path.name
235
+ matches = [key for key in sources if name.startswith(f"{key}-")]
236
+ if not matches:
237
+ return None
238
+ return max(matches, key=len)
239
+
240
+
241
+ def _info(log_level: str, message: str) -> None:
242
+ if log_level in {"info", "debug"}:
243
+ click.echo(message)
244
+
245
+
246
+ def _debug(log_level: str, message: str) -> None:
247
+ if log_level == "debug":
248
+ click.echo(f"DEBUG {message}")
249
+
250
+
251
+ def _print_stream_urls(log_level: str, stream) -> None:
252
+ discovered = stream.discovered_url or stream.url
253
+ _debug(log_level, f"Discovered HLS URL: {discovered}")
254
+ if stream.input_urls:
255
+ for index, input_url in enumerate(stream.input_urls, start=1):
256
+ _info(log_level, f"Recording HLS input {index}: {input_url}")
257
+ return
258
+ _info(log_level, f"Recording HLS URL: {stream.url}")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
@@ -0,0 +1,446 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import urllib.request
9
+ import zipfile
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+
15
+ COMSKIP_VERSION = "0.82.012"
16
+ COMSKIP_WINDOWS_URL = "https://www.kaashoek.com/files/comskip82_012.zip"
17
+ COMSKIP_WINDOWS_SHA256: str | None = None
18
+
19
+ DEFAULT_INI = {
20
+ "output_edl": 1,
21
+ "output_txt": 1,
22
+ "live_tv": 1,
23
+ }
24
+ MIN_KEEP_INTERVAL_SECONDS = 1.0
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ComskipPlan:
29
+ recording_path: Path
30
+ edl_path: Path
31
+ ini_path: Path
32
+ commercial_free_path: Path
33
+ command: list[str]
34
+ options: dict
35
+
36
+
37
+ def build_comskip_plan(
38
+ recording_path: Path,
39
+ *,
40
+ comskip_path: str = "comskip",
41
+ require_comskip: bool = True,
42
+ auto_install: bool = False,
43
+ options: dict | None = None,
44
+ log: Callable[[str], None] | None = None,
45
+ ) -> ComskipPlan:
46
+ comskip = _resolve_comskip(
47
+ comskip_path,
48
+ require_comskip=require_comskip,
49
+ auto_install=auto_install,
50
+ log=log,
51
+ )
52
+ ini_path = recording_path.with_suffix(".comskip.ini")
53
+ edl_path = recording_path.with_suffix(".edl")
54
+ commercial_free_path = recording_path.with_name(
55
+ f"{recording_path.stem}.commercial-free.mp4"
56
+ )
57
+ command = [
58
+ comskip,
59
+ f"--ini={ini_path}",
60
+ f"--output={recording_path.parent}",
61
+ str(recording_path),
62
+ ]
63
+ return ComskipPlan(
64
+ recording_path=recording_path,
65
+ edl_path=edl_path,
66
+ ini_path=ini_path,
67
+ commercial_free_path=commercial_free_path,
68
+ command=command,
69
+ options=options or {},
70
+ )
71
+
72
+
73
+ def run_comskip(plan: ComskipPlan, *, debug: bool = False) -> int:
74
+ plan.ini_path.write_text(_build_ini(plan.options), encoding="utf-8")
75
+ output = None if debug else subprocess.DEVNULL
76
+ completed = subprocess.run(plan.command, stdout=output, stderr=output)
77
+ if not plan.edl_path.exists():
78
+ if completed.returncode != 0:
79
+ return completed.returncode
80
+ print(f"Comskip completed, but no EDL file was created at {plan.edl_path}.")
81
+ return 1
82
+ if _processing_completed(plan):
83
+ return 0
84
+ if completed.returncode != 0:
85
+ return completed.returncode
86
+ return 0
87
+
88
+
89
+ def _processing_completed(plan: ComskipPlan) -> bool:
90
+ txt_path = plan.recording_path.with_suffix(".txt")
91
+ if not txt_path.exists():
92
+ return False
93
+ content = txt_path.read_text(encoding="utf-8", errors="replace")
94
+ return "FILE PROCESSING COMPLETE" in content
95
+
96
+
97
+ def _build_ini(options: dict) -> str:
98
+ settings = {**DEFAULT_INI, **(options.get("ini") or {})}
99
+ return "".join(f"{key}={value}\n" for key, value in settings.items())
100
+
101
+
102
+ def cut_commercials(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False) -> int:
103
+ cuts = _filter_cuts(_read_edl_cuts(plan.edl_path), plan.options)
104
+ duration = _media_duration_seconds(ffmpeg_path, plan.recording_path) if cuts else None
105
+ if cuts and duration is None:
106
+ print(f"Could not determine media duration for {plan.recording_path}.")
107
+ return 1
108
+
109
+ keep_intervals = _keep_intervals(cuts, duration)
110
+ if len(keep_intervals) == 1 and keep_intervals[0][0] == 0 and keep_intervals[0][1] is None:
111
+ return _remux_to_mp4(
112
+ ffmpeg_path,
113
+ plan.recording_path,
114
+ plan.commercial_free_path,
115
+ debug=debug,
116
+ )
117
+ if len(keep_intervals) == 1:
118
+ start, end = keep_intervals[0]
119
+ return _copy_interval_to_mp4(
120
+ ffmpeg_path,
121
+ plan.recording_path,
122
+ plan.commercial_free_path,
123
+ start=start,
124
+ end=end,
125
+ debug=debug,
126
+ )
127
+
128
+ temp_dir = plan.recording_path.with_name(f".{plan.recording_path.stem}.comskip-parts")
129
+ temp_dir.mkdir(parents=True, exist_ok=True)
130
+ output = None if debug else subprocess.DEVNULL
131
+ segment_paths: list[Path] = []
132
+ try:
133
+ for index, (start, end) in enumerate(keep_intervals, start=1):
134
+ segment_path = temp_dir / f"part-{index:04d}.mp4"
135
+ command = [
136
+ ffmpeg_path,
137
+ "-y",
138
+ "-hide_banner",
139
+ "-loglevel",
140
+ "info",
141
+ "-ss",
142
+ _format_seconds(start),
143
+ "-i",
144
+ str(plan.recording_path),
145
+ ]
146
+ if end is not None:
147
+ command.extend(["-t", _format_seconds(end - start)])
148
+ command.extend([
149
+ "-map",
150
+ "0",
151
+ "-dn",
152
+ "-sn",
153
+ "-c",
154
+ "copy",
155
+ "-avoid_negative_ts",
156
+ "make_zero",
157
+ str(segment_path),
158
+ ])
159
+ completed = subprocess.run(command, stdout=output, stderr=output)
160
+ if completed.returncode != 0:
161
+ return completed.returncode
162
+ segment_paths.append(segment_path)
163
+
164
+ concat_path = temp_dir / "concat.txt"
165
+ concat_path.write_text(
166
+ "".join(f"file '{_concat_path(path)}'\n" for path in segment_paths),
167
+ encoding="utf-8",
168
+ )
169
+ command = [
170
+ ffmpeg_path,
171
+ "-y",
172
+ "-hide_banner",
173
+ "-loglevel",
174
+ "info",
175
+ "-f",
176
+ "concat",
177
+ "-safe",
178
+ "0",
179
+ "-i",
180
+ str(concat_path),
181
+ "-c",
182
+ "copy",
183
+ "-movflags",
184
+ "+faststart",
185
+ str(plan.commercial_free_path),
186
+ ]
187
+ completed = subprocess.run(command, stdout=output, stderr=output)
188
+ return completed.returncode
189
+ finally:
190
+ shutil.rmtree(temp_dir, ignore_errors=True)
191
+
192
+
193
+ def _read_edl_cuts(path: Path) -> list[tuple[float, float]]:
194
+ if not path.exists():
195
+ return []
196
+ cuts = []
197
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
198
+ parts = line.split()
199
+ if len(parts) < 2:
200
+ continue
201
+ try:
202
+ start = float(parts[0])
203
+ end = float(parts[1])
204
+ except ValueError:
205
+ continue
206
+ if end > start:
207
+ cuts.append((start, end))
208
+ return sorted(cuts)
209
+
210
+
211
+ def _filter_cuts(cuts: list[tuple[float, float]], options: dict) -> list[tuple[float, float]]:
212
+ if not cuts:
213
+ return []
214
+
215
+ min_segment_seconds = float(options.get("min_segment_seconds") or 0)
216
+ min_break_seconds = float(options.get("min_break_seconds") or 0)
217
+ gap_tolerance_seconds = float(options.get("break_gap_tolerance_seconds") or 0)
218
+
219
+ filtered = [
220
+ (start, end)
221
+ for start, end in cuts
222
+ if end - start >= min_segment_seconds
223
+ ]
224
+ if not filtered or not min_break_seconds:
225
+ return filtered
226
+
227
+ breaks: list[list[tuple[float, float]]] = []
228
+ for cut in filtered:
229
+ if not breaks or cut[0] - breaks[-1][-1][1] > gap_tolerance_seconds:
230
+ breaks.append([cut])
231
+ else:
232
+ breaks[-1].append(cut)
233
+
234
+ kept: list[tuple[float, float]] = []
235
+ for commercial_break in breaks:
236
+ break_duration = sum(end - start for start, end in commercial_break)
237
+ if break_duration >= min_break_seconds:
238
+ kept.extend(commercial_break)
239
+ return kept
240
+
241
+
242
+ def _keep_intervals(cuts: list[tuple[float, float]], duration: float | None) -> list[tuple[float, float | None]]:
243
+ if not cuts:
244
+ return [(0, None)]
245
+
246
+ assert duration is not None
247
+ intervals: list[tuple[float, float | None]] = []
248
+ cursor = 0.0
249
+ for start, end in cuts:
250
+ start = max(0.0, min(start, duration))
251
+ end = max(0.0, min(end, duration))
252
+ if start > cursor:
253
+ intervals.append((cursor, start))
254
+ cursor = max(cursor, end)
255
+ if cursor < duration:
256
+ intervals.append((cursor, duration))
257
+ return [
258
+ (start, end)
259
+ for start, end in intervals
260
+ if end is None or end - start >= MIN_KEEP_INTERVAL_SECONDS
261
+ ]
262
+
263
+
264
+ def _remux_to_mp4(ffmpeg_path: str, input_path: Path, output_path: Path, *, debug: bool) -> int:
265
+ output = None if debug else subprocess.DEVNULL
266
+ command = [
267
+ ffmpeg_path,
268
+ "-y",
269
+ "-hide_banner",
270
+ "-loglevel",
271
+ "info",
272
+ "-i",
273
+ str(input_path),
274
+ "-map",
275
+ "0",
276
+ "-dn",
277
+ "-sn",
278
+ "-c",
279
+ "copy",
280
+ "-movflags",
281
+ "+faststart",
282
+ str(output_path),
283
+ ]
284
+ completed = subprocess.run(command, stdout=output, stderr=output)
285
+ return completed.returncode
286
+
287
+
288
+ def _copy_interval_to_mp4(
289
+ ffmpeg_path: str,
290
+ input_path: Path,
291
+ output_path: Path,
292
+ *,
293
+ start: float,
294
+ end: float | None,
295
+ debug: bool,
296
+ ) -> int:
297
+ output = None if debug else subprocess.DEVNULL
298
+ command = [
299
+ ffmpeg_path,
300
+ "-y",
301
+ "-hide_banner",
302
+ "-loglevel",
303
+ "info",
304
+ "-ss",
305
+ _format_seconds(start),
306
+ "-i",
307
+ str(input_path),
308
+ ]
309
+ if end is not None:
310
+ command.extend(["-t", _format_seconds(end - start)])
311
+ command.extend([
312
+ "-map",
313
+ "0",
314
+ "-dn",
315
+ "-sn",
316
+ "-c",
317
+ "copy",
318
+ "-avoid_negative_ts",
319
+ "make_zero",
320
+ "-movflags",
321
+ "+faststart",
322
+ str(output_path),
323
+ ])
324
+ completed = subprocess.run(command, stdout=output, stderr=output)
325
+ return completed.returncode
326
+
327
+
328
+ def _media_duration_seconds(ffmpeg_path: str, path: Path) -> float | None:
329
+ completed = subprocess.run(
330
+ [ffmpeg_path, "-hide_banner", "-i", str(path)],
331
+ capture_output=True,
332
+ text=True,
333
+ encoding="utf-8",
334
+ errors="replace",
335
+ )
336
+ match = re.search(r"Duration: (?P<hours>\d+):(?P<minutes>\d+):(?P<seconds>\d+(?:\.\d+)?)", completed.stderr)
337
+ if not match:
338
+ return None
339
+ return (
340
+ int(match.group("hours")) * 3600
341
+ + int(match.group("minutes")) * 60
342
+ + float(match.group("seconds"))
343
+ )
344
+
345
+
346
+ def _format_seconds(value: float) -> str:
347
+ return f"{value:.3f}"
348
+
349
+
350
+ def _concat_path(path: Path) -> str:
351
+ return path.resolve().as_posix().replace("'", "'\\''")
352
+
353
+
354
+ def _resolve_comskip(
355
+ comskip_path: str,
356
+ *,
357
+ require_comskip: bool,
358
+ auto_install: bool = False,
359
+ log: Callable[[str], None] | None = None,
360
+ ) -> str:
361
+ resolved = shutil.which(comskip_path)
362
+ if resolved:
363
+ return resolved
364
+ if comskip_path != "comskip":
365
+ return comskip_path
366
+ installed = _installed_comskip_path()
367
+ if installed.exists():
368
+ return str(installed)
369
+ if auto_install:
370
+ return str(install_comskip(log=log))
371
+ if require_comskip:
372
+ raise RuntimeError(
373
+ "Comskip was not found. On Windows, run with --comskip to auto-install it "
374
+ "inside the active Python environment."
375
+ )
376
+ return comskip_path
377
+
378
+
379
+ def install_comskip(log: Callable[[str], None] | None = None) -> Path:
380
+ if platform.system() != "Windows":
381
+ raise RuntimeError("Automatic Comskip installation is currently supported on Windows only.")
382
+
383
+ target = _installed_comskip_path()
384
+ if target.exists():
385
+ return target
386
+
387
+ install_dir = target.parent
388
+ install_dir.mkdir(parents=True, exist_ok=True)
389
+ archive_path = install_dir / "comskip.zip"
390
+
391
+ _log(log, f"Downloading Comskip {COMSKIP_VERSION}...")
392
+ urllib.request.urlretrieve(COMSKIP_WINDOWS_URL, archive_path)
393
+ if COMSKIP_WINDOWS_SHA256:
394
+ _verify_sha256(archive_path, COMSKIP_WINDOWS_SHA256)
395
+ else:
396
+ _log(log, "Comskip checksum is not pinned; trusting the HTTPS download source.")
397
+
398
+ _log(log, f"Installing Comskip to {install_dir}")
399
+ with zipfile.ZipFile(archive_path) as archive:
400
+ _extract_zip_safely(archive, install_dir)
401
+
402
+ archive_path.unlink(missing_ok=True)
403
+ discovered = _find_comskip_exe(install_dir)
404
+ if not discovered:
405
+ raise RuntimeError(f"Comskip was downloaded, but comskip.exe was not found in {install_dir}.")
406
+ if discovered != target:
407
+ discovered.replace(target)
408
+ return target
409
+
410
+
411
+ def _installed_comskip_path() -> Path:
412
+ return _comskip_install_dir() / "comskip.exe"
413
+
414
+
415
+ def _comskip_install_dir() -> Path:
416
+ return Path(sys.prefix) / "share" / "tv-recorder" / "comskip" / COMSKIP_VERSION
417
+
418
+
419
+ def _verify_sha256(path: Path, expected: str) -> None:
420
+ import hashlib
421
+
422
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
423
+ if digest.casefold() != expected.casefold():
424
+ path.unlink(missing_ok=True)
425
+ raise RuntimeError("Downloaded Comskip archive did not match the expected SHA256 checksum.")
426
+
427
+
428
+ def _extract_zip_safely(archive: zipfile.ZipFile, target_dir: Path) -> None:
429
+ root = target_dir.resolve()
430
+ for member in archive.infolist():
431
+ target = (target_dir / member.filename).resolve()
432
+ if root != target and root not in target.parents:
433
+ raise RuntimeError(f"Unsafe path in Comskip archive: {member.filename}")
434
+ archive.extractall(target_dir)
435
+
436
+
437
+ def _find_comskip_exe(root: Path) -> Path | None:
438
+ for path in root.rglob("*"):
439
+ if path.name.casefold() == "comskip.exe":
440
+ return path
441
+ return None
442
+
443
+
444
+ def _log(log: Callable[[str], None] | None, message: str) -> None:
445
+ if log:
446
+ log(message)
@@ -20,6 +20,7 @@ class SourceConfig:
20
20
  stream_url_reject_patterns: tuple[str, ...] = ()
21
21
  output_extension: str = "mp4"
22
22
  recording: dict[str, Any] | None = None
23
+ comskip: dict[str, Any] | None = None
23
24
  steps: tuple[dict[str, Any], ...] = ()
24
25
  user_agent: str | None = None
25
26
 
@@ -54,6 +55,7 @@ def get_source(config: dict[str, Any], source_key: str) -> SourceConfig:
54
55
  stream_url_reject_patterns=tuple(raw.get("stream_url_reject_patterns") or ()),
55
56
  output_extension=raw.get("output_extension") or "mp4",
56
57
  recording=raw.get("recording"),
58
+ comskip=raw.get("comskip"),
57
59
  steps=tuple(raw.get("steps") or ()),
58
60
  user_agent=raw.get("user_agent"),
59
61
  )
@@ -21,6 +21,16 @@ sources:
21
21
  comment: "Français"
22
22
  reject_comments:
23
23
  - "audio_dv"
24
+ comskip:
25
+ min_segment_seconds: 15
26
+ min_break_seconds: 105
27
+ break_gap_tolerance_seconds: 2
28
+ ini:
29
+ min_commercial_size: 25
30
+ min_commercialbreak: 105
31
+ max_commercialbreak: 600
32
+ delete_show_before_first_commercial: 0
33
+ delete_show_after_last_commercial: 0
24
34
  steps: []
25
35
  user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36"
26
36
  tvaplus.ca:
@@ -36,6 +46,16 @@ sources:
36
46
  direct_variant: false
37
47
  audio:
38
48
  language: "fre"
49
+ comskip:
50
+ min_segment_seconds: 15
51
+ min_break_seconds: 105
52
+ break_gap_tolerance_seconds: 2
53
+ ini:
54
+ min_commercial_size: 25
55
+ min_commercialbreak: 105
56
+ max_commercialbreak: 600
57
+ delete_show_before_first_commercial: 0
58
+ delete_show_after_last_commercial: 0
39
59
  steps:
40
60
  - action: wait_for_stream
41
61
  user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36"
@@ -0,0 +1,37 @@
1
+ from pathlib import Path
2
+
3
+ from tv_recorder.cli import _source_key_from_recording
4
+
5
+
6
+ def test_source_key_from_recording_uses_filename_prefix() -> None:
7
+ config = {
8
+ "sources": {
9
+ "tvaplus.ca": {},
10
+ "radio-canada.ca": {},
11
+ }
12
+ }
13
+
14
+ assert (
15
+ _source_key_from_recording(
16
+ config,
17
+ Path("recordings/tvaplus.ca-20260529-190942.mp4"),
18
+ )
19
+ == "tvaplus.ca"
20
+ )
21
+
22
+
23
+ def test_source_key_from_recording_uses_longest_match() -> None:
24
+ config = {
25
+ "sources": {
26
+ "globalnews": {},
27
+ "globalnews-national": {},
28
+ }
29
+ }
30
+
31
+ assert (
32
+ _source_key_from_recording(
33
+ config,
34
+ Path("recordings/globalnews-national-20260529-190942.mp4"),
35
+ )
36
+ == "globalnews-national"
37
+ )
@@ -0,0 +1,188 @@
1
+ from pathlib import Path
2
+ import shutil
3
+ from uuid import uuid4
4
+ from unittest.mock import patch
5
+
6
+ import tv_recorder.comskip as comskip
7
+ from tv_recorder.comskip import ComskipPlan, build_comskip_plan, cut_commercials, run_comskip
8
+
9
+
10
+ def test_build_comskip_plan_uses_sidecar_paths() -> None:
11
+ recording = Path("recordings/show.ts")
12
+
13
+ plan = build_comskip_plan(
14
+ recording,
15
+ comskip_path="custom-comskip",
16
+ require_comskip=False,
17
+ )
18
+
19
+ assert plan.recording_path == recording
20
+ assert plan.edl_path == Path("recordings/show.edl")
21
+ assert plan.ini_path == Path("recordings/show.comskip.ini")
22
+ assert plan.commercial_free_path == Path("recordings/show.commercial-free.mp4")
23
+ assert plan.command == [
24
+ "custom-comskip",
25
+ f"--ini={Path('recordings/show.comskip.ini')}",
26
+ f"--output={Path('recordings')}",
27
+ str(Path("recordings/show.ts")),
28
+ ]
29
+
30
+
31
+ def test_comskip_install_dir_lives_under_python_prefix(monkeypatch) -> None:
32
+ python_prefix = Path("C:/python-env")
33
+ monkeypatch.setattr(comskip.sys, "prefix", str(python_prefix))
34
+
35
+ assert comskip._comskip_install_dir() == (
36
+ python_prefix / "share" / "tv-recorder" / "comskip" / comskip.COMSKIP_VERSION
37
+ )
38
+
39
+
40
+ def test_run_comskip_accepts_completed_processing_with_nonzero_exit() -> None:
41
+ work_dir = Path("recordings") / f"tv-recorder-comskip-test-{uuid4().hex}"
42
+ work_dir.mkdir(parents=True)
43
+ try:
44
+ recording = work_dir / "show.mp4"
45
+ recording.write_bytes(b"video")
46
+ plan = ComskipPlan(
47
+ recording_path=recording,
48
+ edl_path=work_dir / "show.edl",
49
+ ini_path=work_dir / "show.comskip.ini",
50
+ commercial_free_path=work_dir / "show.commercial-free.mp4",
51
+ command=["comskip", str(recording)],
52
+ options={
53
+ "min_segment_seconds": 15,
54
+ "min_break_seconds": 105,
55
+ },
56
+ )
57
+ plan.edl_path.write_text("1.0\t2.0\t0\n", encoding="utf-8")
58
+ recording.with_suffix(".txt").write_text(
59
+ "FILE PROCESSING COMPLETE 100 FRAMES AT 2997\n",
60
+ encoding="utf-8",
61
+ )
62
+
63
+ with patch("tv_recorder.comskip.subprocess.run") as run:
64
+ run.return_value.returncode = 1
65
+
66
+ assert run_comskip(plan) == 0
67
+ finally:
68
+ shutil.rmtree(work_dir, ignore_errors=True)
69
+
70
+
71
+ def test_read_edl_cuts() -> None:
72
+ work_dir = Path("recordings") / f"tv-recorder-comskip-test-{uuid4().hex}"
73
+ work_dir.mkdir(parents=True)
74
+ try:
75
+ edl_path = work_dir / "show.edl"
76
+ edl_path.write_text("10.0\t20.5\t0\nbad line\n30\t40\t0\n", encoding="utf-8")
77
+
78
+ assert comskip._read_edl_cuts(edl_path) == [(10.0, 20.5), (30.0, 40.0)]
79
+ finally:
80
+ shutil.rmtree(work_dir, ignore_errors=True)
81
+
82
+
83
+ def test_keep_intervals_are_complement_of_cuts() -> None:
84
+ assert comskip._keep_intervals([(10.0, 20.0), (30.0, 40.0)], 50.0) == [
85
+ (0.0, 10.0),
86
+ (20.0, 30.0),
87
+ (40.0, 50.0),
88
+ ]
89
+
90
+
91
+ def test_cut_commercials_remuxes_when_edl_is_empty() -> None:
92
+ plan = ComskipPlan(
93
+ recording_path=Path("recordings/show.mp4"),
94
+ edl_path=Path("recordings/show.edl"),
95
+ ini_path=Path("recordings/show.comskip.ini"),
96
+ commercial_free_path=Path("recordings/show.commercial-free.mp4"),
97
+ command=["comskip", "recordings/show.mp4"],
98
+ options={},
99
+ )
100
+
101
+ with patch("tv_recorder.comskip.subprocess.run") as run:
102
+ run.return_value.returncode = 0
103
+
104
+ assert cut_commercials(plan, ffmpeg_path="ffmpeg") == 0
105
+
106
+ command = run.call_args.args[0]
107
+ assert command[-1] == str(plan.commercial_free_path)
108
+
109
+
110
+ def test_cut_commercials_copies_single_remaining_interval() -> None:
111
+ work_dir = Path("recordings") / f"tv-recorder-comskip-test-{uuid4().hex}"
112
+ work_dir.mkdir(parents=True)
113
+ try:
114
+ recording = work_dir / "show.mp4"
115
+ recording.write_bytes(b"video")
116
+ edl_path = work_dir / "show.edl"
117
+ edl_path.write_text("0.07\t234.11\t0\n", encoding="utf-8")
118
+ plan = ComskipPlan(
119
+ recording_path=recording,
120
+ edl_path=edl_path,
121
+ ini_path=work_dir / "show.comskip.ini",
122
+ commercial_free_path=work_dir / "show.commercial-free.mp4",
123
+ command=["comskip", str(recording)],
124
+ options={},
125
+ )
126
+
127
+ with (
128
+ patch("tv_recorder.comskip._media_duration_seconds", return_value=1500.05),
129
+ patch("tv_recorder.comskip.subprocess.run") as run,
130
+ ):
131
+ run.return_value.returncode = 0
132
+
133
+ assert cut_commercials(plan, ffmpeg_path="ffmpeg") == 0
134
+
135
+ command = run.call_args.args[0]
136
+ assert command[:8] == [
137
+ "ffmpeg",
138
+ "-y",
139
+ "-hide_banner",
140
+ "-loglevel",
141
+ "info",
142
+ "-ss",
143
+ "234.110",
144
+ "-i",
145
+ ]
146
+ assert "-f" not in command
147
+ assert command[-1] == str(plan.commercial_free_path)
148
+ finally:
149
+ shutil.rmtree(work_dir, ignore_errors=True)
150
+
151
+
152
+ def test_build_ini_merges_channel_options() -> None:
153
+ ini = comskip._build_ini({
154
+ "ini": {
155
+ "min_commercialbreak": 105,
156
+ "max_commercialbreak": 600,
157
+ }
158
+ })
159
+
160
+ assert "output_edl=1\n" in ini
161
+ assert "min_commercialbreak=105\n" in ini
162
+ assert "max_commercialbreak=600\n" in ini
163
+
164
+
165
+ def test_filter_cuts_rejects_short_false_positive() -> None:
166
+ cuts = [(598.73, 599.93)]
167
+ options = {
168
+ "min_segment_seconds": 15,
169
+ "min_break_seconds": 105,
170
+ }
171
+
172
+ assert comskip._filter_cuts(cuts, options) == []
173
+
174
+
175
+ def test_filter_cuts_keeps_long_break() -> None:
176
+ cuts = [
177
+ (100.0, 130.0),
178
+ (130.5, 160.5),
179
+ (161.0, 191.0),
180
+ (191.5, 221.5),
181
+ ]
182
+ options = {
183
+ "min_segment_seconds": 15,
184
+ "min_break_seconds": 105,
185
+ "break_gap_tolerance_seconds": 2,
186
+ }
187
+
188
+ assert comskip._filter_cuts(cuts, options) == cuts
@@ -1,126 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import shlex
5
- import sys
6
- import time
7
- from pathlib import Path
8
-
9
- from tv_recorder.config import get_source, load_config
10
- from tv_recorder.duration import parse_duration, parse_start, seconds_until
11
- from tv_recorder.recorder import build_ffmpeg_plan, build_output_path, run_recording
12
- from tv_recorder.stream_finder import find_stream
13
-
14
-
15
- def build_parser() -> argparse.ArgumentParser:
16
- parser = argparse.ArgumentParser(
17
- prog="tv-recorder",
18
- description="Find an HLS stream with Playwright and record it with ffmpeg.",
19
- )
20
- parser.add_argument("source", nargs="?", help="Source defined in YAML, ex: radio-canada.ca")
21
- parser.add_argument("start", nargs="?", help="'now' or local ISO date, ex: 2026-05-24T20:00:00")
22
- parser.add_argument("duration", nargs="?", help="Duration, ex: 30m, 2h, 90s, 01:30:00")
23
- parser.add_argument("--config", type=Path, help="Path to a YAML source config file.")
24
- parser.add_argument("--list", action="store_true", help="List available sources.")
25
- parser.add_argument("--output-dir", type=Path, default=Path("recordings"), help="Output directory.")
26
- parser.add_argument("--headful", action="store_true", help="Show Chromium while detecting the stream.")
27
- parser.add_argument("--timeout-ms", type=int, default=45_000, help="Playwright timeout in milliseconds.")
28
- parser.add_argument("--ffmpeg", default="ffmpeg", help="Override the bundled imageio-ffmpeg binary.")
29
- parser.add_argument("--dry-run", action="store_true", help="Detect the stream and print the ffmpeg command.")
30
- log_group = parser.add_mutually_exclusive_group()
31
- log_group.add_argument("--info", dest="log_level", action="store_const", const="info", default="info", help="Show normal progress and selected HLS URLs.")
32
- log_group.add_argument("--debug", dest="log_level", action="store_const", const="debug", help="Show discovery steps and ffmpeg output.")
33
- return parser
34
-
35
-
36
- def main(argv: list[str] | None = None) -> int:
37
- parser = build_parser()
38
- args = parser.parse_args(argv)
39
-
40
- try:
41
- config = load_config(args.config)
42
- if args.list:
43
- list_sources(config)
44
- return 0
45
-
46
- missing = [name for name in ("source", "start", "duration") if getattr(args, name) is None]
47
- if missing:
48
- parser.error("source, start, and duration are required unless --list is used")
49
-
50
- source = get_source(config, args.source)
51
-
52
- start = parse_start(args.start)
53
- duration_seconds = parse_duration(args.duration)
54
- delay = seconds_until(start)
55
-
56
- if delay > 0:
57
- _info(args, f"Waiting until {start.isoformat()} ({int(delay)} s).")
58
- time.sleep(delay)
59
-
60
- _info(args, f"Detecting stream for {source.display_name}...")
61
- stream = find_stream(
62
- source,
63
- headless=not args.headful,
64
- timeout_ms=args.timeout_ms,
65
- debug_log=lambda message: _debug(args, message),
66
- )
67
- output_path = build_output_path(args.output_dir, source, start)
68
- plan = build_ffmpeg_plan(
69
- stream,
70
- output_path,
71
- duration_seconds,
72
- ffmpeg_path=args.ffmpeg,
73
- require_ffmpeg=not args.dry_run,
74
- recording=source.recording,
75
- )
76
- _print_stream_urls(args, stream)
77
-
78
- if args.dry_run:
79
- print("ffmpeg command:")
80
- print(shlex.join(plan.command))
81
- return 0
82
-
83
- _info(args, f"Recording to {plan.output_path}")
84
- exit_code = run_recording(plan, debug=args.log_level == "debug")
85
- if exit_code != 0:
86
- print(f"ffmpeg exited with code {exit_code}.", file=sys.stderr)
87
- return exit_code
88
- except Exception as exc:
89
- print(f"Error: {exc}", file=sys.stderr)
90
- return 1
91
-
92
-
93
- def list_sources(config: dict) -> None:
94
- sources = config.get("sources") or {}
95
- if not sources:
96
- print("No sources available.")
97
- return
98
-
99
- width = max(len(key) for key in sources)
100
- for key in sorted(sources):
101
- display_name = sources[key].get("display_name") or key
102
- print(f"{key.ljust(width)} {display_name}")
103
-
104
-
105
- def _info(args, message: str) -> None:
106
- if args.log_level in {"info", "debug"}:
107
- print(message, flush=True)
108
-
109
-
110
- def _debug(args, message: str) -> None:
111
- if args.log_level == "debug":
112
- print(f"DEBUG {message}", flush=True)
113
-
114
-
115
- def _print_stream_urls(args, stream) -> None:
116
- discovered = stream.discovered_url or stream.url
117
- _debug(args, f"Discovered HLS URL: {discovered}")
118
- if stream.input_urls:
119
- for index, input_url in enumerate(stream.input_urls, start=1):
120
- _info(args, f"Recording HLS input {index}: {input_url}")
121
- return
122
- _info(args, f"Recording HLS URL: {stream.url}")
123
-
124
-
125
- if __name__ == "__main__":
126
- raise SystemExit(main())