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.
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/PKG-INFO +41 -3
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/README.md +39 -2
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/pyproject.toml +6 -8
- tv_recorder-0.0.0.dev4/requirements.txt +5 -0
- tv_recorder-0.0.0.dev4/src/tv_recorder/cli.py +309 -0
- tv_recorder-0.0.0.dev4/src/tv_recorder/comskip.py +537 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/config.py +2 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/defaults.yaml +21 -0
- tv_recorder-0.0.0.dev4/src/tv_recorder/gui.py +376 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/recorder.py +71 -21
- tv_recorder-0.0.0.dev4/tests/test_cli.py +92 -0
- tv_recorder-0.0.0.dev4/tests/test_comskip.py +221 -0
- tv_recorder-0.0.0.dev4/tests/test_gui.py +22 -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.dev4}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.github/workflows/publish.yml +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/.gitignore +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/LICENSE +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/scripts/check_version.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/__init__.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/duration.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/src/tv_recorder/stream_finder.py +0 -0
- {tv_recorder-0.0.0.dev2 → tv_recorder-0.0.0.dev4}/tests/test_duration.py +0 -0
- {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.
|
|
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
|
-
##
|
|
59
|
+
## Installation
|
|
59
60
|
|
|
60
61
|
```powershell
|
|
61
|
-
|
|
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
|
-
##
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
7
|
```powershell
|
|
8
|
-
|
|
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.
|
|
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
|
-
|
|
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,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()
|