tv-recorder 0.0.0.dev2__tar.gz → 0.0.0.dev4__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 (26) hide show
  1. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/PKG-INFO +41 -3
  2. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/README.md +39 -2
  3. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/pyproject.toml +6 -8
  4. tv_recorder-0.0.0.dev4/requirements.txt +5 -0
  5. tv_recorder-0.0.0.dev4/src/tv_recorder/cli.py +309 -0
  6. tv_recorder-0.0.0.dev4/src/tv_recorder/comskip.py +537 -0
  7. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/config.py +2 -0
  8. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/defaults.yaml +21 -0
  9. tv_recorder-0.0.0.dev4/src/tv_recorder/gui.py +376 -0
  10. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/recorder.py +71 -21
  11. tv_recorder-0.0.0.dev4/tests/test_cli.py +92 -0
  12. tv_recorder-0.0.0.dev4/tests/test_comskip.py +221 -0
  13. tv_recorder-0.0.0.dev4/tests/test_gui.py +22 -0
  14. tv_recorder-0.0.0.dev2/src/tv_recorder/cli.py +0 -126
  15. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
  16. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
  17. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
  18. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.github/workflows/publish.yml +0 -0
  19. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.gitignore +0 -0
  20. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/LICENSE +0 -0
  21. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/scripts/check_version.py +0 -0
  22. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/__init__.py +0 -0
  23. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/duration.py +0 -0
  24. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/stream_finder.py +0 -0
  25. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/tests/test_duration.py +0 -0
  26. {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/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.dev4
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
@@ -55,18 +56,34 @@ Description-Content-Type: text/markdown
55
56
 
56
57
  Command-line recorder for public live TV streams.
57
58
 
58
- ## Local Installation
59
+ ## Installation
59
60
 
60
61
  ```powershell
61
- python -m pip install -e .
62
+ pipx install tv-recorder
62
63
  ```
63
64
 
64
65
  Chromium is installed automatically on first use if Playwright does not already have it.
65
66
 
66
67
  The recorder uses the `ffmpeg` binary provided by `imageio-ffmpeg`.
67
68
 
69
+ For local development from a checkout:
70
+
71
+ ```powershell
72
+ pipx install --editable .
73
+ ```
74
+
68
75
  ## Usage
69
76
 
77
+ Start `tv-recorder` without arguments to open the native desktop GUI:
78
+
79
+ ```powershell
80
+ tv-recorder
81
+ ```
82
+
83
+ The GUI uses Python's built-in Tkinter toolkit and exposes the common recording
84
+ and Comskip options with a log panel for command output. Custom YAML
85
+ configuration remains available from the CLI.
86
+
70
87
  ```powershell
71
88
  tv-recorder radio-canada.ca now 2h
72
89
  ```
@@ -92,12 +109,32 @@ tv-recorder globalnews-montreal now 30m
92
109
  tv-recorder radio-canada.ca now 10m --headful
93
110
  tv-recorder radio-canada.ca now 10m --dry-run
94
111
  tv-recorder radio-canada.ca now 10m --debug
112
+ tv-recorder radio-canada.ca now 10m --comskip
113
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
95
114
  ```
96
115
 
97
116
  `START` accepts `now` or a local ISO date. `DURATION` accepts values such as `90s`, `30m`, `2h`, or `01:30:00`.
98
117
 
99
118
  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
119
 
120
+ ## Commercial Marking
121
+
122
+ 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:
123
+
124
+ ```powershell
125
+ tv-recorder radio-canada.ca now 1h --comskip
126
+ ```
127
+
128
+ Run the same post-processing on an existing recording:
129
+
130
+ ```powershell
131
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
132
+ ```
133
+
134
+ The channel settings are selected from the file name prefix, such as `tvaplus.ca-...mp4`.
135
+
136
+ 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.
137
+
101
138
  ## Configuration YAML
102
139
 
103
140
  The package includes a default configuration. To replace it, provide your own YAML file:
@@ -115,6 +152,7 @@ Each source can define:
115
152
  - `stream_response_json_keys`: JSON keys whose values should be treated as stream URLs.
116
153
  - `stream_url_reject_patterns`: stream URL patterns to ignore.
117
154
  - `recording`: video/audio track selection.
155
+ - `comskip`: commercial marking and cutting settings.
118
156
  - `steps`: browser interaction recipe before stream detection.
119
157
  - `output_extension`: output file extension.
120
158
  - `user_agent`: optional user-agent for browser and recorder requests.
@@ -2,18 +2,34 @@
2
2
 
3
3
  Command-line recorder for public live TV streams.
4
4
 
5
- ## Local Installation
5
+ ## Installation
6
6
 
7
7
  ```powershell
8
- python -m pip install -e .
8
+ pipx install tv-recorder
9
9
  ```
10
10
 
11
11
  Chromium is installed automatically on first use if Playwright does not already have it.
12
12
 
13
13
  The recorder uses the `ffmpeg` binary provided by `imageio-ffmpeg`.
14
14
 
15
+ For local development from a checkout:
16
+
17
+ ```powershell
18
+ pipx install --editable .
19
+ ```
20
+
15
21
  ## Usage
16
22
 
23
+ Start `tv-recorder` without arguments to open the native desktop GUI:
24
+
25
+ ```powershell
26
+ tv-recorder
27
+ ```
28
+
29
+ The GUI uses Python's built-in Tkinter toolkit and exposes the common recording
30
+ and Comskip options with a log panel for command output. Custom YAML
31
+ configuration remains available from the CLI.
32
+
17
33
  ```powershell
18
34
  tv-recorder radio-canada.ca now 2h
19
35
  ```
@@ -39,12 +55,32 @@ tv-recorder globalnews-montreal now 30m
39
55
  tv-recorder radio-canada.ca now 10m --headful
40
56
  tv-recorder radio-canada.ca now 10m --dry-run
41
57
  tv-recorder radio-canada.ca now 10m --debug
58
+ tv-recorder radio-canada.ca now 10m --comskip
59
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
42
60
  ```
43
61
 
44
62
  `START` accepts `now` or a local ISO date. `DURATION` accepts values such as `90s`, `30m`, `2h`, or `01:30:00`.
45
63
 
46
64
  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
65
 
66
+ ## Commercial Marking
67
+
68
+ 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:
69
+
70
+ ```powershell
71
+ tv-recorder radio-canada.ca now 1h --comskip
72
+ ```
73
+
74
+ Run the same post-processing on an existing recording:
75
+
76
+ ```powershell
77
+ tv-recorder comskip recordings\tvaplus.ca-20260529-190942.mp4
78
+ ```
79
+
80
+ The channel settings are selected from the file name prefix, such as `tvaplus.ca-...mp4`.
81
+
82
+ 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.
83
+
48
84
  ## Configuration YAML
49
85
 
50
86
  The package includes a default configuration. To replace it, provide your own YAML file:
@@ -62,6 +98,7 @@ Each source can define:
62
98
  - `stream_response_json_keys`: JSON keys whose values should be treated as stream URLs.
63
99
  - `stream_url_reject_patterns`: stream URL patterns to ignore.
64
100
  - `recording`: video/audio track selection.
101
+ - `comskip`: commercial marking and cutting settings.
65
102
  - `steps`: browser interaction recipe before stream detection.
66
103
  - `output_extension`: output file extension.
67
104
  - `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.dev4"
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,309 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import sys
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from tv_recorder import recorder
13
+ from tv_recorder.comskip import build_comskip_plan, cut_commercials, run_comskip
14
+ from tv_recorder.config import get_source, load_config
15
+ from tv_recorder.duration import parse_duration, parse_start, seconds_until
16
+ from tv_recorder.recorder import build_ffmpeg_plan, build_output_path, run_recording
17
+ from tv_recorder.stream_finder import find_stream
18
+
19
+
20
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
21
+ @click.argument("source", required=False)
22
+ @click.argument("start", required=False)
23
+ @click.argument("duration", required=False)
24
+ @click.option("--config", "config_path", type=click.Path(path_type=Path), help="Path to a YAML source config file.")
25
+ @click.option("--list", "list_channels", is_flag=True, help="List available sources.")
26
+ @click.option("--output-dir", type=click.Path(path_type=Path), default=Path.cwd, show_default="current directory", help="Output directory.")
27
+ @click.option("--headful", is_flag=True, help="Show Chromium while detecting the stream.")
28
+ @click.option("--timeout-ms", type=int, default=45_000, show_default=True, help="Playwright timeout in milliseconds.")
29
+ @click.option("--ffmpeg", "ffmpeg_path", default="ffmpeg", show_default=True, help="Override the bundled imageio-ffmpeg binary.")
30
+ @click.option("--comskip", is_flag=True, help="Run Comskip after recording and create a commercial-free MP4.")
31
+ @click.option("--dry-run", is_flag=True, help="Detect the stream and print the ffmpeg command.")
32
+ @click.option("--info", "log_level", flag_value="info", default="info", help="Show normal progress and selected HLS URLs.")
33
+ @click.option("--debug", "log_level", flag_value="debug", help="Show discovery steps and ffmpeg output.")
34
+ def main(
35
+ source: str | None,
36
+ start: str | None,
37
+ duration: str | None,
38
+ config_path: Path | None,
39
+ list_channels: bool,
40
+ output_dir: Path,
41
+ headful: bool,
42
+ timeout_ms: int,
43
+ ffmpeg_path: str,
44
+ comskip: bool,
45
+ dry_run: bool,
46
+ log_level: str,
47
+ ) -> None:
48
+ """Find an HLS stream and record it."""
49
+ try:
50
+ if _launched_without_arguments():
51
+ from tv_recorder.gui import main as gui_main
52
+
53
+ gui_main()
54
+ return
55
+
56
+ config = load_config(config_path)
57
+ if list_channels:
58
+ list_sources(config)
59
+ return
60
+
61
+ if source == "comskip":
62
+ if start is None or duration is not None:
63
+ raise click.UsageError("comskip requires exactly one recording file path")
64
+ exit_code = run_existing_comskip(
65
+ config,
66
+ Path(start),
67
+ ffmpeg_path=ffmpeg_path,
68
+ log_level=log_level,
69
+ )
70
+ raise click.exceptions.Exit(exit_code)
71
+
72
+ if source is None or start is None or duration is None:
73
+ raise click.UsageError("source, start, and duration are required unless --list is used")
74
+
75
+ exit_code = run_record_command(
76
+ config,
77
+ source_key=source,
78
+ start_value=start,
79
+ duration_value=duration,
80
+ output_dir=output_dir,
81
+ headful=headful,
82
+ timeout_ms=timeout_ms,
83
+ ffmpeg_path=ffmpeg_path,
84
+ comskip=comskip,
85
+ dry_run=dry_run,
86
+ log_level=log_level,
87
+ )
88
+ raise click.exceptions.Exit(exit_code)
89
+ except click.exceptions.Exit:
90
+ raise
91
+ except click.ClickException:
92
+ raise
93
+ except Exception as exc:
94
+ click.echo(f"Error: {exc}", err=True)
95
+ raise click.exceptions.Exit(1)
96
+
97
+
98
+ def run_record_command(
99
+ config: dict,
100
+ *,
101
+ source_key: str,
102
+ start_value: str,
103
+ duration_value: str,
104
+ output_dir: Path,
105
+ headful: bool,
106
+ timeout_ms: int,
107
+ ffmpeg_path: str,
108
+ comskip: bool,
109
+ dry_run: bool,
110
+ log_level: str,
111
+ activity_callback: Callable[[str], None] | None = None,
112
+ comskip_activity_callback: Callable[[str], None] | None = None,
113
+ cancellation_event: threading.Event | None = None,
114
+ ) -> int:
115
+ source = get_source(config, source_key)
116
+ start = parse_start(start_value)
117
+ duration_seconds = parse_duration(duration_value)
118
+ delay = seconds_until(start)
119
+
120
+ if delay > 0:
121
+ _info(log_level, f"Waiting until {start.isoformat()} ({int(delay)} s).")
122
+ if cancellation_event and cancellation_event.wait(delay):
123
+ _info(log_level, "Cancelled before recording started.")
124
+ return 130
125
+
126
+ if cancellation_event and cancellation_event.is_set():
127
+ _info(log_level, "Cancelled before stream detection.")
128
+ return 130
129
+
130
+ _info(log_level, f"Detecting stream for {source.display_name}...")
131
+ stream = find_stream(
132
+ source,
133
+ headless=not headful,
134
+ timeout_ms=timeout_ms,
135
+ debug_log=lambda message: _debug(log_level, message),
136
+ )
137
+
138
+ if cancellation_event and cancellation_event.is_set():
139
+ _info(log_level, "Cancelled before recording started.")
140
+ return 130
141
+
142
+ output_path = build_output_path(output_dir, source, start)
143
+ plan = build_ffmpeg_plan(
144
+ stream,
145
+ output_path,
146
+ duration_seconds,
147
+ ffmpeg_path=ffmpeg_path,
148
+ require_ffmpeg=not dry_run,
149
+ recording=source.recording,
150
+ )
151
+ _print_stream_urls(log_level, stream)
152
+
153
+ if dry_run:
154
+ click.echo("ffmpeg command:")
155
+ click.echo(shlex.join(plan.command))
156
+ if comskip:
157
+ comskip_plan = build_comskip_plan(
158
+ plan.output_path,
159
+ require_comskip=False,
160
+ auto_install=False,
161
+ options=source.comskip,
162
+ )
163
+ click.echo("comskip command:")
164
+ click.echo(shlex.join(comskip_plan.command))
165
+ return 0
166
+
167
+ _info(log_level, f"Recording to {plan.output_path}")
168
+ exit_code = run_recording(
169
+ plan,
170
+ debug=log_level == "debug",
171
+ activity_callback=activity_callback,
172
+ cancellation_event=cancellation_event,
173
+ )
174
+ if exit_code != 0:
175
+ click.echo(f"ffmpeg exited with code {exit_code}.", err=True)
176
+ return exit_code
177
+
178
+ if comskip:
179
+ return run_comskip_pipeline(
180
+ config,
181
+ plan.output_path,
182
+ ffmpeg_path=plan.ffmpeg_path,
183
+ log_level=log_level,
184
+ activity_callback=comskip_activity_callback,
185
+ cancellation_event=cancellation_event,
186
+ )
187
+
188
+ return 0
189
+
190
+
191
+ def list_sources(config: dict) -> None:
192
+ sources = config.get("sources") or {}
193
+ if not sources:
194
+ click.echo("No sources available.")
195
+ return
196
+
197
+ width = max(len(key) for key in sources)
198
+ for key in sorted(sources):
199
+ display_name = sources[key].get("display_name") or key
200
+ click.echo(f"{key.ljust(width)} {display_name}")
201
+
202
+
203
+ def run_existing_comskip(
204
+ config: dict,
205
+ recording_path: Path,
206
+ *,
207
+ ffmpeg_path: str,
208
+ log_level: str,
209
+ activity_callback: Callable[[str], None] | None = None,
210
+ cancellation_event: threading.Event | None = None,
211
+ ) -> int:
212
+ if not recording_path.exists():
213
+ raise FileNotFoundError(recording_path)
214
+ resolved_ffmpeg = recorder._resolve_ffmpeg(ffmpeg_path, require_ffmpeg=True)
215
+ return run_comskip_pipeline(
216
+ config,
217
+ recording_path,
218
+ ffmpeg_path=resolved_ffmpeg,
219
+ log_level=log_level,
220
+ activity_callback=activity_callback,
221
+ cancellation_event=cancellation_event,
222
+ )
223
+
224
+
225
+ def run_comskip_pipeline(
226
+ config: dict,
227
+ recording_path: Path,
228
+ *,
229
+ ffmpeg_path: str,
230
+ log_level: str,
231
+ activity_callback: Callable[[str], None] | None = None,
232
+ cancellation_event: threading.Event | None = None,
233
+ ) -> int:
234
+ source_key = _source_key_from_recording(config, recording_path)
235
+ source = get_source(config, source_key) if source_key else None
236
+ if source:
237
+ _info(log_level, f"Using Comskip settings for {source.display_name}.")
238
+ else:
239
+ _info(log_level, "Using default Comskip settings.")
240
+
241
+ comskip_plan = build_comskip_plan(
242
+ recording_path,
243
+ auto_install=True,
244
+ options=source.comskip if source else None,
245
+ log=lambda message: _info(log_level, message),
246
+ )
247
+ _info(log_level, f"Running Comskip on {comskip_plan.recording_path}")
248
+ _debug(log_level, f"Comskip command: {shlex.join(comskip_plan.command)}")
249
+ exit_code = run_comskip(
250
+ comskip_plan,
251
+ debug=log_level == "debug",
252
+ activity_callback=activity_callback,
253
+ cancellation_event=cancellation_event,
254
+ )
255
+ if exit_code != 0:
256
+ click.echo(f"comskip exited with code {exit_code}.", err=True)
257
+ return exit_code
258
+
259
+ _info(log_level, f"Comskip EDL: {comskip_plan.edl_path}")
260
+ _info(log_level, f"Writing commercial-free MP4 to {comskip_plan.commercial_free_path}")
261
+ exit_code = cut_commercials(
262
+ comskip_plan,
263
+ ffmpeg_path=ffmpeg_path,
264
+ debug=log_level == "debug",
265
+ activity_callback=activity_callback,
266
+ cancellation_event=cancellation_event,
267
+ )
268
+ if exit_code != 0:
269
+ click.echo(f"commercial cutting exited with code {exit_code}.", err=True)
270
+ return exit_code
271
+ _info(log_level, f"Commercial-free MP4: {comskip_plan.commercial_free_path}")
272
+ return 0
273
+
274
+
275
+ def _source_key_from_recording(config: dict, recording_path: Path) -> str | None:
276
+ sources = config.get("sources") or {}
277
+ name = recording_path.name
278
+ matches = [key for key in sources if name.startswith(f"{key}-")]
279
+ if not matches:
280
+ return None
281
+ return max(matches, key=len)
282
+
283
+
284
+ def _info(log_level: str, message: str) -> None:
285
+ if log_level in {"info", "debug"}:
286
+ click.echo(message)
287
+
288
+
289
+ def _debug(log_level: str, message: str) -> None:
290
+ if log_level == "debug":
291
+ click.echo(f"DEBUG {message}")
292
+
293
+
294
+ def _print_stream_urls(log_level: str, stream) -> None:
295
+ discovered = stream.discovered_url or stream.url
296
+ _debug(log_level, f"Discovered HLS URL: {discovered}")
297
+ if stream.input_urls:
298
+ for index, input_url in enumerate(stream.input_urls, start=1):
299
+ _info(log_level, f"Recording HLS input {index}: {input_url}")
300
+ return
301
+ _info(log_level, f"Recording HLS URL: {stream.url}")
302
+
303
+
304
+ def _launched_without_arguments() -> bool:
305
+ return len(sys.argv) == 1
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()