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.
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/PKG-INFO +23 -1
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/README.md +21 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/pyproject.toml +6 -8
- tv_recorder-0.0.0.dev3/requirements.txt +5 -0
- tv_recorder-0.0.0.dev3/src/tv_recorder/cli.py +262 -0
- tv_recorder-0.0.0.dev3/src/tv_recorder/comskip.py +446 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/config.py +2 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/defaults.yaml +20 -0
- tv_recorder-0.0.0.dev3/tests/test_cli.py +37 -0
- tv_recorder-0.0.0.dev3/tests/test_comskip.py +188 -0
- tv_recorder-0.0.0.dev2/src/tv_recorder/cli.py +0 -126
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.github/workflows/publish.yml +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.gitignore +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/LICENSE +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/scripts/check_version.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/__init__.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/duration.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/recorder.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/src/tv_recorder/stream_finder.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/tests/test_duration.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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,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())
|
{tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev3}/.codex/skills/tv-recorder-add-channel/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|