tv-recorder 0.0.0.dev3__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.dev3 → tv_recorder-0.0.0.dev4}/PKG-INFO +19 -3
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/README.md +18 -2
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/pyproject.toml +1 -1
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/cli.py +51 -4
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/comskip.py +103 -12
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/defaults.yaml +1 -0
- tv_recorder-0.0.0.dev4/src/tv_recorder/gui.py +376 -0
- {tv_recorder-0.0.0.dev3 → 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.dev3 → tv_recorder-0.0.0.dev4}/tests/test_comskip.py +33 -0
- tv_recorder-0.0.0.dev4/tests/test_gui.py +22 -0
- tv_recorder-0.0.0.dev3/tests/test_cli.py +0 -37
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.github/workflows/publish.yml +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.gitignore +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/LICENSE +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/requirements.txt +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/scripts/check_version.py +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/__init__.py +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/config.py +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/duration.py +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/stream_finder.py +0 -0
- {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/tests/test_duration.py +0 -0
- {tv_recorder-0.0.0.dev3 → 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
|
|
@@ -56,18 +56,34 @@ Description-Content-Type: text/markdown
|
|
|
56
56
|
|
|
57
57
|
Command-line recorder for public live TV streams.
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## Installation
|
|
60
60
|
|
|
61
61
|
```powershell
|
|
62
|
-
|
|
62
|
+
pipx install tv-recorder
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Chromium is installed automatically on first use if Playwright does not already have it.
|
|
66
66
|
|
|
67
67
|
The recorder uses the `ffmpeg` binary provided by `imageio-ffmpeg`.
|
|
68
68
|
|
|
69
|
+
For local development from a checkout:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
pipx install --editable .
|
|
73
|
+
```
|
|
74
|
+
|
|
69
75
|
## Usage
|
|
70
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
|
+
|
|
71
87
|
```powershell
|
|
72
88
|
tv-recorder radio-canada.ca now 2h
|
|
73
89
|
```
|
|
@@ -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
|
```
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import shlex
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
4
6
|
import time
|
|
7
|
+
from collections.abc import Callable
|
|
5
8
|
from pathlib import Path
|
|
6
9
|
|
|
7
10
|
import click
|
|
@@ -20,7 +23,7 @@ from tv_recorder.stream_finder import find_stream
|
|
|
20
23
|
@click.argument("duration", required=False)
|
|
21
24
|
@click.option("--config", "config_path", type=click.Path(path_type=Path), help="Path to a YAML source config file.")
|
|
22
25
|
@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
|
|
26
|
+
@click.option("--output-dir", type=click.Path(path_type=Path), default=Path.cwd, show_default="current directory", help="Output directory.")
|
|
24
27
|
@click.option("--headful", is_flag=True, help="Show Chromium while detecting the stream.")
|
|
25
28
|
@click.option("--timeout-ms", type=int, default=45_000, show_default=True, help="Playwright timeout in milliseconds.")
|
|
26
29
|
@click.option("--ffmpeg", "ffmpeg_path", default="ffmpeg", show_default=True, help="Override the bundled imageio-ffmpeg binary.")
|
|
@@ -44,6 +47,12 @@ def main(
|
|
|
44
47
|
) -> None:
|
|
45
48
|
"""Find an HLS stream and record it."""
|
|
46
49
|
try:
|
|
50
|
+
if _launched_without_arguments():
|
|
51
|
+
from tv_recorder.gui import main as gui_main
|
|
52
|
+
|
|
53
|
+
gui_main()
|
|
54
|
+
return
|
|
55
|
+
|
|
47
56
|
config = load_config(config_path)
|
|
48
57
|
if list_channels:
|
|
49
58
|
list_sources(config)
|
|
@@ -99,6 +108,9 @@ def run_record_command(
|
|
|
99
108
|
comskip: bool,
|
|
100
109
|
dry_run: bool,
|
|
101
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,
|
|
102
114
|
) -> int:
|
|
103
115
|
source = get_source(config, source_key)
|
|
104
116
|
start = parse_start(start_value)
|
|
@@ -107,7 +119,13 @@ def run_record_command(
|
|
|
107
119
|
|
|
108
120
|
if delay > 0:
|
|
109
121
|
_info(log_level, f"Waiting until {start.isoformat()} ({int(delay)} s).")
|
|
110
|
-
|
|
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
|
|
111
129
|
|
|
112
130
|
_info(log_level, f"Detecting stream for {source.display_name}...")
|
|
113
131
|
stream = find_stream(
|
|
@@ -116,6 +134,11 @@ def run_record_command(
|
|
|
116
134
|
timeout_ms=timeout_ms,
|
|
117
135
|
debug_log=lambda message: _debug(log_level, message),
|
|
118
136
|
)
|
|
137
|
+
|
|
138
|
+
if cancellation_event and cancellation_event.is_set():
|
|
139
|
+
_info(log_level, "Cancelled before recording started.")
|
|
140
|
+
return 130
|
|
141
|
+
|
|
119
142
|
output_path = build_output_path(output_dir, source, start)
|
|
120
143
|
plan = build_ffmpeg_plan(
|
|
121
144
|
stream,
|
|
@@ -142,7 +165,12 @@ def run_record_command(
|
|
|
142
165
|
return 0
|
|
143
166
|
|
|
144
167
|
_info(log_level, f"Recording to {plan.output_path}")
|
|
145
|
-
exit_code = run_recording(
|
|
168
|
+
exit_code = run_recording(
|
|
169
|
+
plan,
|
|
170
|
+
debug=log_level == "debug",
|
|
171
|
+
activity_callback=activity_callback,
|
|
172
|
+
cancellation_event=cancellation_event,
|
|
173
|
+
)
|
|
146
174
|
if exit_code != 0:
|
|
147
175
|
click.echo(f"ffmpeg exited with code {exit_code}.", err=True)
|
|
148
176
|
return exit_code
|
|
@@ -153,6 +181,8 @@ def run_record_command(
|
|
|
153
181
|
plan.output_path,
|
|
154
182
|
ffmpeg_path=plan.ffmpeg_path,
|
|
155
183
|
log_level=log_level,
|
|
184
|
+
activity_callback=comskip_activity_callback,
|
|
185
|
+
cancellation_event=cancellation_event,
|
|
156
186
|
)
|
|
157
187
|
|
|
158
188
|
return 0
|
|
@@ -176,6 +206,8 @@ def run_existing_comskip(
|
|
|
176
206
|
*,
|
|
177
207
|
ffmpeg_path: str,
|
|
178
208
|
log_level: str,
|
|
209
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
210
|
+
cancellation_event: threading.Event | None = None,
|
|
179
211
|
) -> int:
|
|
180
212
|
if not recording_path.exists():
|
|
181
213
|
raise FileNotFoundError(recording_path)
|
|
@@ -185,6 +217,8 @@ def run_existing_comskip(
|
|
|
185
217
|
recording_path,
|
|
186
218
|
ffmpeg_path=resolved_ffmpeg,
|
|
187
219
|
log_level=log_level,
|
|
220
|
+
activity_callback=activity_callback,
|
|
221
|
+
cancellation_event=cancellation_event,
|
|
188
222
|
)
|
|
189
223
|
|
|
190
224
|
|
|
@@ -194,6 +228,8 @@ def run_comskip_pipeline(
|
|
|
194
228
|
*,
|
|
195
229
|
ffmpeg_path: str,
|
|
196
230
|
log_level: str,
|
|
231
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
232
|
+
cancellation_event: threading.Event | None = None,
|
|
197
233
|
) -> int:
|
|
198
234
|
source_key = _source_key_from_recording(config, recording_path)
|
|
199
235
|
source = get_source(config, source_key) if source_key else None
|
|
@@ -210,7 +246,12 @@ def run_comskip_pipeline(
|
|
|
210
246
|
)
|
|
211
247
|
_info(log_level, f"Running Comskip on {comskip_plan.recording_path}")
|
|
212
248
|
_debug(log_level, f"Comskip command: {shlex.join(comskip_plan.command)}")
|
|
213
|
-
exit_code = run_comskip(
|
|
249
|
+
exit_code = run_comskip(
|
|
250
|
+
comskip_plan,
|
|
251
|
+
debug=log_level == "debug",
|
|
252
|
+
activity_callback=activity_callback,
|
|
253
|
+
cancellation_event=cancellation_event,
|
|
254
|
+
)
|
|
214
255
|
if exit_code != 0:
|
|
215
256
|
click.echo(f"comskip exited with code {exit_code}.", err=True)
|
|
216
257
|
return exit_code
|
|
@@ -221,6 +262,8 @@ def run_comskip_pipeline(
|
|
|
221
262
|
comskip_plan,
|
|
222
263
|
ffmpeg_path=ffmpeg_path,
|
|
223
264
|
debug=log_level == "debug",
|
|
265
|
+
activity_callback=activity_callback,
|
|
266
|
+
cancellation_event=cancellation_event,
|
|
224
267
|
)
|
|
225
268
|
if exit_code != 0:
|
|
226
269
|
click.echo(f"commercial cutting exited with code {exit_code}.", err=True)
|
|
@@ -258,5 +301,9 @@ def _print_stream_urls(log_level: str, stream) -> None:
|
|
|
258
301
|
_info(log_level, f"Recording HLS URL: {stream.url}")
|
|
259
302
|
|
|
260
303
|
|
|
304
|
+
def _launched_without_arguments() -> bool:
|
|
305
|
+
return len(sys.argv) == 1
|
|
306
|
+
|
|
307
|
+
|
|
261
308
|
if __name__ == "__main__":
|
|
262
309
|
main()
|
|
@@ -5,12 +5,15 @@ import re
|
|
|
5
5
|
import shutil
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
+
import threading
|
|
8
9
|
import urllib.request
|
|
9
10
|
import zipfile
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
from tv_recorder.recorder import _ActivityIndicator
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
COMSKIP_VERSION = "0.82.012"
|
|
16
19
|
COMSKIP_WINDOWS_URL = "https://www.kaashoek.com/files/comskip82_012.zip"
|
|
@@ -70,10 +73,21 @@ def build_comskip_plan(
|
|
|
70
73
|
)
|
|
71
74
|
|
|
72
75
|
|
|
73
|
-
def run_comskip(
|
|
76
|
+
def run_comskip(
|
|
77
|
+
plan: ComskipPlan,
|
|
78
|
+
*,
|
|
79
|
+
debug: bool = False,
|
|
80
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
81
|
+
cancellation_event: threading.Event | None = None,
|
|
82
|
+
) -> int:
|
|
74
83
|
plan.ini_path.write_text(_build_ini(plan.options), encoding="utf-8")
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
completed = _run_command(
|
|
85
|
+
plan.command,
|
|
86
|
+
debug=debug,
|
|
87
|
+
activity_callback=activity_callback,
|
|
88
|
+
activity_label="comskip activity",
|
|
89
|
+
cancellation_event=cancellation_event,
|
|
90
|
+
)
|
|
77
91
|
if not plan.edl_path.exists():
|
|
78
92
|
if completed.returncode != 0:
|
|
79
93
|
return completed.returncode
|
|
@@ -99,7 +113,14 @@ def _build_ini(options: dict) -> str:
|
|
|
99
113
|
return "".join(f"{key}={value}\n" for key, value in settings.items())
|
|
100
114
|
|
|
101
115
|
|
|
102
|
-
def cut_commercials(
|
|
116
|
+
def cut_commercials(
|
|
117
|
+
plan: ComskipPlan,
|
|
118
|
+
*,
|
|
119
|
+
ffmpeg_path: str,
|
|
120
|
+
debug: bool = False,
|
|
121
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
122
|
+
cancellation_event: threading.Event | None = None,
|
|
123
|
+
) -> int:
|
|
103
124
|
cuts = _filter_cuts(_read_edl_cuts(plan.edl_path), plan.options)
|
|
104
125
|
duration = _media_duration_seconds(ffmpeg_path, plan.recording_path) if cuts else None
|
|
105
126
|
if cuts and duration is None:
|
|
@@ -113,6 +134,8 @@ def cut_commercials(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False)
|
|
|
113
134
|
plan.recording_path,
|
|
114
135
|
plan.commercial_free_path,
|
|
115
136
|
debug=debug,
|
|
137
|
+
activity_callback=activity_callback,
|
|
138
|
+
cancellation_event=cancellation_event,
|
|
116
139
|
)
|
|
117
140
|
if len(keep_intervals) == 1:
|
|
118
141
|
start, end = keep_intervals[0]
|
|
@@ -123,11 +146,12 @@ def cut_commercials(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False)
|
|
|
123
146
|
start=start,
|
|
124
147
|
end=end,
|
|
125
148
|
debug=debug,
|
|
149
|
+
activity_callback=activity_callback,
|
|
150
|
+
cancellation_event=cancellation_event,
|
|
126
151
|
)
|
|
127
152
|
|
|
128
153
|
temp_dir = plan.recording_path.with_name(f".{plan.recording_path.stem}.comskip-parts")
|
|
129
154
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
-
output = None if debug else subprocess.DEVNULL
|
|
131
155
|
segment_paths: list[Path] = []
|
|
132
156
|
try:
|
|
133
157
|
for index, (start, end) in enumerate(keep_intervals, start=1):
|
|
@@ -156,7 +180,13 @@ def cut_commercials(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False)
|
|
|
156
180
|
"make_zero",
|
|
157
181
|
str(segment_path),
|
|
158
182
|
])
|
|
159
|
-
completed =
|
|
183
|
+
completed = _run_command(
|
|
184
|
+
command,
|
|
185
|
+
debug=debug,
|
|
186
|
+
activity_callback=activity_callback,
|
|
187
|
+
activity_label="comskip activity",
|
|
188
|
+
cancellation_event=cancellation_event,
|
|
189
|
+
)
|
|
160
190
|
if completed.returncode != 0:
|
|
161
191
|
return completed.returncode
|
|
162
192
|
segment_paths.append(segment_path)
|
|
@@ -184,7 +214,13 @@ def cut_commercials(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False)
|
|
|
184
214
|
"+faststart",
|
|
185
215
|
str(plan.commercial_free_path),
|
|
186
216
|
]
|
|
187
|
-
completed =
|
|
217
|
+
completed = _run_command(
|
|
218
|
+
command,
|
|
219
|
+
debug=debug,
|
|
220
|
+
activity_callback=activity_callback,
|
|
221
|
+
activity_label="comskip activity",
|
|
222
|
+
cancellation_event=cancellation_event,
|
|
223
|
+
)
|
|
188
224
|
return completed.returncode
|
|
189
225
|
finally:
|
|
190
226
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
@@ -261,8 +297,15 @@ def _keep_intervals(cuts: list[tuple[float, float]], duration: float | None) ->
|
|
|
261
297
|
]
|
|
262
298
|
|
|
263
299
|
|
|
264
|
-
def _remux_to_mp4(
|
|
265
|
-
|
|
300
|
+
def _remux_to_mp4(
|
|
301
|
+
ffmpeg_path: str,
|
|
302
|
+
input_path: Path,
|
|
303
|
+
output_path: Path,
|
|
304
|
+
*,
|
|
305
|
+
debug: bool,
|
|
306
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
307
|
+
cancellation_event: threading.Event | None = None,
|
|
308
|
+
) -> int:
|
|
266
309
|
command = [
|
|
267
310
|
ffmpeg_path,
|
|
268
311
|
"-y",
|
|
@@ -281,7 +324,13 @@ def _remux_to_mp4(ffmpeg_path: str, input_path: Path, output_path: Path, *, debu
|
|
|
281
324
|
"+faststart",
|
|
282
325
|
str(output_path),
|
|
283
326
|
]
|
|
284
|
-
completed =
|
|
327
|
+
completed = _run_command(
|
|
328
|
+
command,
|
|
329
|
+
debug=debug,
|
|
330
|
+
activity_callback=activity_callback,
|
|
331
|
+
activity_label="comskip activity",
|
|
332
|
+
cancellation_event=cancellation_event,
|
|
333
|
+
)
|
|
285
334
|
return completed.returncode
|
|
286
335
|
|
|
287
336
|
|
|
@@ -293,8 +342,9 @@ def _copy_interval_to_mp4(
|
|
|
293
342
|
start: float,
|
|
294
343
|
end: float | None,
|
|
295
344
|
debug: bool,
|
|
345
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
346
|
+
cancellation_event: threading.Event | None = None,
|
|
296
347
|
) -> int:
|
|
297
|
-
output = None if debug else subprocess.DEVNULL
|
|
298
348
|
command = [
|
|
299
349
|
ffmpeg_path,
|
|
300
350
|
"-y",
|
|
@@ -321,10 +371,51 @@ def _copy_interval_to_mp4(
|
|
|
321
371
|
"+faststart",
|
|
322
372
|
str(output_path),
|
|
323
373
|
])
|
|
324
|
-
completed =
|
|
374
|
+
completed = _run_command(
|
|
375
|
+
command,
|
|
376
|
+
debug=debug,
|
|
377
|
+
activity_callback=activity_callback,
|
|
378
|
+
activity_label="comskip activity",
|
|
379
|
+
cancellation_event=cancellation_event,
|
|
380
|
+
)
|
|
325
381
|
return completed.returncode
|
|
326
382
|
|
|
327
383
|
|
|
384
|
+
def _run_command(
|
|
385
|
+
command: list[str],
|
|
386
|
+
*,
|
|
387
|
+
debug: bool,
|
|
388
|
+
activity_callback: Callable[[str], None] | None,
|
|
389
|
+
activity_label: str,
|
|
390
|
+
cancellation_event: threading.Event | None = None,
|
|
391
|
+
) -> subprocess.CompletedProcess:
|
|
392
|
+
output = None if debug else subprocess.DEVNULL
|
|
393
|
+
if activity_callback is None and cancellation_event is None:
|
|
394
|
+
return subprocess.run(command, stdout=output, stderr=output)
|
|
395
|
+
|
|
396
|
+
process = subprocess.Popen(
|
|
397
|
+
command,
|
|
398
|
+
stdout=subprocess.PIPE if activity_callback else output,
|
|
399
|
+
stderr=subprocess.STDOUT,
|
|
400
|
+
)
|
|
401
|
+
activity = _ActivityIndicator(activity_label, callback=activity_callback)
|
|
402
|
+
activity.start(process.stdout if activity_callback else None)
|
|
403
|
+
try:
|
|
404
|
+
while True:
|
|
405
|
+
if cancellation_event and cancellation_event.is_set():
|
|
406
|
+
process.terminate()
|
|
407
|
+
return_code = process.wait()
|
|
408
|
+
return subprocess.CompletedProcess(command, return_code)
|
|
409
|
+
try:
|
|
410
|
+
return_code = process.wait(timeout=0.2)
|
|
411
|
+
break
|
|
412
|
+
except subprocess.TimeoutExpired:
|
|
413
|
+
continue
|
|
414
|
+
finally:
|
|
415
|
+
activity.stop()
|
|
416
|
+
return subprocess.CompletedProcess(command, return_code)
|
|
417
|
+
|
|
418
|
+
|
|
328
419
|
def _media_duration_seconds(ffmpeg_path: str, path: Path) -> float | None:
|
|
329
420
|
completed = subprocess.run(
|
|
330
421
|
[ffmpeg_path, "-hide_banner", "-i", str(path)],
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tkinter import filedialog
|
|
10
|
+
from tkinter import messagebox
|
|
11
|
+
from tkinter import ttk
|
|
12
|
+
from tkinter.scrolledtext import ScrolledText
|
|
13
|
+
|
|
14
|
+
from tv_recorder.cli import run_existing_comskip, run_record_command
|
|
15
|
+
from tv_recorder.config import load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_DONE_MESSAGE = "__TV_RECORDER_DONE__"
|
|
19
|
+
_ACTIVITY_PREFIX = "__TV_RECORDER_ACTIVITY__"
|
|
20
|
+
_COMSKIP_ACTIVITY_PREFIX = "__TV_RECORDER_COMSKIP_ACTIVITY__"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _QueueWriter:
|
|
24
|
+
def __init__(self, log_queue: queue.Queue[str]) -> None:
|
|
25
|
+
self._log_queue = log_queue
|
|
26
|
+
self._buffer = ""
|
|
27
|
+
|
|
28
|
+
def write(self, text: str) -> int:
|
|
29
|
+
self._buffer += text
|
|
30
|
+
while "\n" in self._buffer:
|
|
31
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
32
|
+
self._log_queue.put(line)
|
|
33
|
+
return len(text)
|
|
34
|
+
|
|
35
|
+
def flush(self) -> None:
|
|
36
|
+
if self._buffer:
|
|
37
|
+
self._log_queue.put(self._buffer)
|
|
38
|
+
self._buffer = ""
|
|
39
|
+
|
|
40
|
+
def isatty(self) -> bool:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RecorderApp:
|
|
45
|
+
def __init__(self, root: tk.Tk) -> None:
|
|
46
|
+
self.root = root
|
|
47
|
+
self.root.title("tv-recorder")
|
|
48
|
+
self.root.geometry("920x680")
|
|
49
|
+
self.root.minsize(760, 560)
|
|
50
|
+
|
|
51
|
+
self.log_queue: queue.Queue[str] = queue.Queue()
|
|
52
|
+
self.worker: threading.Thread | None = None
|
|
53
|
+
self.cancellation_event = threading.Event()
|
|
54
|
+
self.config: dict = {}
|
|
55
|
+
|
|
56
|
+
self.source = tk.StringVar()
|
|
57
|
+
now = datetime.now()
|
|
58
|
+
self.start_now = tk.BooleanVar(value=True)
|
|
59
|
+
self.start_date = tk.StringVar(value=now.strftime("%Y-%m-%d"))
|
|
60
|
+
self.start_hour = tk.IntVar(value=now.hour)
|
|
61
|
+
self.start_minute = tk.IntVar(value=now.minute)
|
|
62
|
+
self.duration = tk.StringVar(value="30m")
|
|
63
|
+
self.output_dir = tk.StringVar(value=str(Path.cwd()))
|
|
64
|
+
self.timeout_ms = tk.IntVar(value=45_000)
|
|
65
|
+
self.comskip = tk.BooleanVar(value=False)
|
|
66
|
+
self.comskip_file = tk.StringVar()
|
|
67
|
+
self.ffmpeg_activity = tk.StringVar(value="ffmpeg: idle")
|
|
68
|
+
self.comskip_activity = tk.StringVar(value="comskip: idle")
|
|
69
|
+
|
|
70
|
+
self._build_ui()
|
|
71
|
+
self._load_config()
|
|
72
|
+
self._poll_logs()
|
|
73
|
+
|
|
74
|
+
def _build_ui(self) -> None:
|
|
75
|
+
self.root.columnconfigure(0, weight=1)
|
|
76
|
+
self.root.rowconfigure(0, weight=1)
|
|
77
|
+
|
|
78
|
+
panes = ttk.PanedWindow(self.root, orient=tk.VERTICAL)
|
|
79
|
+
panes.grid(row=0, column=0, sticky="nsew", padx=12, pady=12)
|
|
80
|
+
|
|
81
|
+
options = ttk.Frame(panes, padding=0)
|
|
82
|
+
options.columnconfigure(0, weight=1)
|
|
83
|
+
panes.add(options, weight=0)
|
|
84
|
+
|
|
85
|
+
notebook = ttk.Notebook(options)
|
|
86
|
+
notebook.grid(row=0, column=0, sticky="ew")
|
|
87
|
+
|
|
88
|
+
record_tab = ttk.Frame(notebook, padding=12)
|
|
89
|
+
record_tab.columnconfigure(1, weight=1)
|
|
90
|
+
notebook.add(record_tab, text="Recording")
|
|
91
|
+
|
|
92
|
+
ttk.Label(record_tab, text="Channel").grid(row=0, column=0, sticky="w", pady=4)
|
|
93
|
+
self.source_combo = ttk.Combobox(record_tab, textvariable=self.source, state="readonly")
|
|
94
|
+
self.source_combo.grid(row=0, column=1, sticky="ew", pady=4)
|
|
95
|
+
|
|
96
|
+
ttk.Label(record_tab, text="Start").grid(row=1, column=0, sticky="w", pady=4)
|
|
97
|
+
start_frame = ttk.Frame(record_tab)
|
|
98
|
+
start_frame.grid(row=1, column=1, sticky="w", pady=4)
|
|
99
|
+
self.start_now_check = ttk.Checkbutton(
|
|
100
|
+
start_frame,
|
|
101
|
+
text="Now",
|
|
102
|
+
variable=self.start_now,
|
|
103
|
+
command=self._sync_start_controls,
|
|
104
|
+
)
|
|
105
|
+
self.start_now_check.grid(row=0, column=0, padx=(0, 12))
|
|
106
|
+
self.start_date_entry = ttk.Entry(start_frame, textvariable=self.start_date, width=12)
|
|
107
|
+
self.start_date_entry.grid(row=0, column=1, padx=(0, 8))
|
|
108
|
+
self.start_hour_spin = ttk.Spinbox(
|
|
109
|
+
start_frame,
|
|
110
|
+
textvariable=self.start_hour,
|
|
111
|
+
from_=0,
|
|
112
|
+
to=23,
|
|
113
|
+
width=3,
|
|
114
|
+
format="%02.0f",
|
|
115
|
+
)
|
|
116
|
+
self.start_hour_spin.grid(row=0, column=2)
|
|
117
|
+
ttk.Label(start_frame, text=":").grid(row=0, column=3, padx=2)
|
|
118
|
+
self.start_minute_spin = ttk.Spinbox(
|
|
119
|
+
start_frame,
|
|
120
|
+
textvariable=self.start_minute,
|
|
121
|
+
from_=0,
|
|
122
|
+
to=59,
|
|
123
|
+
width=3,
|
|
124
|
+
format="%02.0f",
|
|
125
|
+
)
|
|
126
|
+
self.start_minute_spin.grid(row=0, column=4)
|
|
127
|
+
|
|
128
|
+
ttk.Label(record_tab, text="Duration").grid(row=2, column=0, sticky="w", pady=4)
|
|
129
|
+
ttk.Entry(record_tab, textvariable=self.duration).grid(row=2, column=1, sticky="ew", pady=4)
|
|
130
|
+
|
|
131
|
+
ttk.Label(record_tab, text="Output folder").grid(row=3, column=0, sticky="w", pady=4)
|
|
132
|
+
ttk.Entry(record_tab, textvariable=self.output_dir).grid(row=3, column=1, sticky="ew", pady=4)
|
|
133
|
+
ttk.Button(record_tab, text="Browse", command=self._browse_output_dir).grid(row=3, column=2, padx=(8, 0))
|
|
134
|
+
|
|
135
|
+
ttk.Label(record_tab, text="Timeout Playwright").grid(row=4, column=0, sticky="w", pady=4)
|
|
136
|
+
ttk.Spinbox(record_tab, textvariable=self.timeout_ms, from_=1_000, to=300_000, increment=1_000).grid(
|
|
137
|
+
row=4,
|
|
138
|
+
column=1,
|
|
139
|
+
sticky="w",
|
|
140
|
+
pady=4,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
checks = ttk.Frame(record_tab)
|
|
144
|
+
checks.grid(row=5, column=1, sticky="w", pady=(8, 4))
|
|
145
|
+
ttk.Checkbutton(checks, text="Comskip", variable=self.comskip).grid(row=0, column=0)
|
|
146
|
+
|
|
147
|
+
actions = ttk.Frame(record_tab)
|
|
148
|
+
actions.grid(row=6, column=1, sticky="e", pady=(10, 0))
|
|
149
|
+
self.record_button = ttk.Button(actions, text="Start", command=self._start_recording)
|
|
150
|
+
self.record_button.grid(row=0, column=0)
|
|
151
|
+
self.stop_record_button = ttk.Button(actions, text="Stop", command=self._request_stop, state="disabled")
|
|
152
|
+
self.stop_record_button.grid(row=0, column=1, padx=(8, 0))
|
|
153
|
+
|
|
154
|
+
comskip_tab = ttk.Frame(notebook, padding=12)
|
|
155
|
+
comskip_tab.columnconfigure(1, weight=1)
|
|
156
|
+
notebook.add(comskip_tab, text="Comskip")
|
|
157
|
+
|
|
158
|
+
ttk.Label(comskip_tab, text="File").grid(row=0, column=0, sticky="w", pady=4)
|
|
159
|
+
ttk.Entry(comskip_tab, textvariable=self.comskip_file).grid(row=0, column=1, sticky="ew", pady=4)
|
|
160
|
+
ttk.Button(comskip_tab, text="Browse", command=self._browse_comskip_file).grid(row=0, column=2, padx=(8, 0))
|
|
161
|
+
self.comskip_button = ttk.Button(comskip_tab, text="Run Comskip", command=self._start_existing_comskip)
|
|
162
|
+
self.comskip_button.grid(row=1, column=1, sticky="e", pady=(10, 0))
|
|
163
|
+
self.stop_comskip_button = ttk.Button(comskip_tab, text="Stop", command=self._request_stop, state="disabled")
|
|
164
|
+
self.stop_comskip_button.grid(row=1, column=2, sticky="e", padx=(8, 0), pady=(10, 0))
|
|
165
|
+
|
|
166
|
+
log_frame = ttk.Frame(panes)
|
|
167
|
+
log_frame.columnconfigure(0, weight=1)
|
|
168
|
+
log_frame.rowconfigure(1, weight=1)
|
|
169
|
+
panes.add(log_frame, weight=1)
|
|
170
|
+
|
|
171
|
+
log_header = ttk.Frame(log_frame)
|
|
172
|
+
log_header.grid(row=0, column=0, sticky="ew", pady=(10, 4))
|
|
173
|
+
log_header.columnconfigure(0, weight=1)
|
|
174
|
+
ttk.Label(log_header, text="Logs").grid(row=0, column=0, sticky="w")
|
|
175
|
+
ttk.Label(log_header, textvariable=self.ffmpeg_activity, width=18).grid(row=0, column=1, padx=(0, 12))
|
|
176
|
+
ttk.Label(log_header, textvariable=self.comskip_activity, width=20).grid(row=0, column=2, padx=(0, 12))
|
|
177
|
+
ttk.Button(log_header, text="Clear", command=self._clear_logs).grid(row=0, column=3)
|
|
178
|
+
|
|
179
|
+
self.logs = ScrolledText(log_frame, height=16, wrap=tk.WORD, state="disabled")
|
|
180
|
+
self.logs.grid(row=1, column=0, sticky="nsew")
|
|
181
|
+
self._sync_start_controls()
|
|
182
|
+
|
|
183
|
+
def _browse_output_dir(self) -> None:
|
|
184
|
+
path = filedialog.askdirectory(title="Choose the output folder")
|
|
185
|
+
if path:
|
|
186
|
+
self.output_dir.set(path)
|
|
187
|
+
|
|
188
|
+
def _browse_comskip_file(self) -> None:
|
|
189
|
+
path = filedialog.askopenfilename(
|
|
190
|
+
title="Choose a recording",
|
|
191
|
+
filetypes=[("Videos", "*.mp4 *.mkv *.ts"), ("All files", "*.*")],
|
|
192
|
+
)
|
|
193
|
+
if path:
|
|
194
|
+
self.comskip_file.set(path)
|
|
195
|
+
|
|
196
|
+
def _load_config(self) -> None:
|
|
197
|
+
try:
|
|
198
|
+
self.config = load_config()
|
|
199
|
+
sources = self.config.get("sources") or {}
|
|
200
|
+
values = [
|
|
201
|
+
f"{key} - {sources[key].get('display_name') or key}"
|
|
202
|
+
for key in sorted(sources)
|
|
203
|
+
]
|
|
204
|
+
self.source_combo["values"] = values
|
|
205
|
+
if values and not self.source.get():
|
|
206
|
+
self.source.set(_default_source_value(self.config, values))
|
|
207
|
+
self._log(f"Loaded configuration: {len(values)} channel(s).")
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
messagebox.showerror("Configuration", str(exc))
|
|
210
|
+
self._log(f"Configuration error: {exc}")
|
|
211
|
+
|
|
212
|
+
def _start_recording(self) -> None:
|
|
213
|
+
if self.worker and self.worker.is_alive():
|
|
214
|
+
return
|
|
215
|
+
source_key = self._selected_source_key()
|
|
216
|
+
if not source_key:
|
|
217
|
+
messagebox.showerror("Recording", "Choose a channel.")
|
|
218
|
+
return
|
|
219
|
+
self.cancellation_event = threading.Event()
|
|
220
|
+
self._set_running(True)
|
|
221
|
+
self.ffmpeg_activity.set("ffmpeg: waiting")
|
|
222
|
+
self.comskip_activity.set("comskip: idle")
|
|
223
|
+
self._log("Starting recording.")
|
|
224
|
+
self.worker = threading.Thread(
|
|
225
|
+
target=self._recording_worker,
|
|
226
|
+
args=(source_key,),
|
|
227
|
+
daemon=True,
|
|
228
|
+
)
|
|
229
|
+
self.worker.start()
|
|
230
|
+
|
|
231
|
+
def _start_existing_comskip(self) -> None:
|
|
232
|
+
if self.worker and self.worker.is_alive():
|
|
233
|
+
return
|
|
234
|
+
if not self.comskip_file.get().strip():
|
|
235
|
+
messagebox.showerror("Comskip", "Choose a recording file.")
|
|
236
|
+
return
|
|
237
|
+
self.cancellation_event = threading.Event()
|
|
238
|
+
self._set_running(True)
|
|
239
|
+
self.ffmpeg_activity.set("ffmpeg: idle")
|
|
240
|
+
self.comskip_activity.set("comskip: waiting")
|
|
241
|
+
self._log("Starting Comskip.")
|
|
242
|
+
self.worker = threading.Thread(target=self._existing_comskip_worker, daemon=True)
|
|
243
|
+
self.worker.start()
|
|
244
|
+
|
|
245
|
+
def _recording_worker(self, source_key: str) -> None:
|
|
246
|
+
writer = _QueueWriter(self.log_queue)
|
|
247
|
+
try:
|
|
248
|
+
with contextlib.redirect_stdout(writer), contextlib.redirect_stderr(writer):
|
|
249
|
+
exit_code = run_record_command(
|
|
250
|
+
self.config,
|
|
251
|
+
source_key=source_key,
|
|
252
|
+
start_value=self._start_value(),
|
|
253
|
+
duration_value=self.duration.get(),
|
|
254
|
+
output_dir=Path(self.output_dir.get()),
|
|
255
|
+
headful=False,
|
|
256
|
+
timeout_ms=int(self.timeout_ms.get()),
|
|
257
|
+
ffmpeg_path="ffmpeg",
|
|
258
|
+
comskip=self.comskip.get(),
|
|
259
|
+
dry_run=False,
|
|
260
|
+
log_level="info",
|
|
261
|
+
activity_callback=self._queue_ffmpeg_activity,
|
|
262
|
+
comskip_activity_callback=self._queue_comskip_activity,
|
|
263
|
+
cancellation_event=self.cancellation_event,
|
|
264
|
+
)
|
|
265
|
+
print(f"Finished with exit code {exit_code}.")
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
self.log_queue.put(f"Error: {exc}")
|
|
268
|
+
finally:
|
|
269
|
+
writer.flush()
|
|
270
|
+
self.log_queue.put(_DONE_MESSAGE)
|
|
271
|
+
|
|
272
|
+
def _existing_comskip_worker(self) -> None:
|
|
273
|
+
writer = _QueueWriter(self.log_queue)
|
|
274
|
+
try:
|
|
275
|
+
with contextlib.redirect_stdout(writer), contextlib.redirect_stderr(writer):
|
|
276
|
+
exit_code = run_existing_comskip(
|
|
277
|
+
self.config,
|
|
278
|
+
Path(self.comskip_file.get()),
|
|
279
|
+
ffmpeg_path="ffmpeg",
|
|
280
|
+
log_level="info",
|
|
281
|
+
activity_callback=self._queue_comskip_activity,
|
|
282
|
+
cancellation_event=self.cancellation_event,
|
|
283
|
+
)
|
|
284
|
+
print(f"Finished with exit code {exit_code}.")
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
self.log_queue.put(f"Error: {exc}")
|
|
287
|
+
finally:
|
|
288
|
+
writer.flush()
|
|
289
|
+
self.log_queue.put(_DONE_MESSAGE)
|
|
290
|
+
|
|
291
|
+
def _selected_source_key(self) -> str:
|
|
292
|
+
value = self.source.get()
|
|
293
|
+
return value.split(" - ", 1)[0].strip()
|
|
294
|
+
|
|
295
|
+
def _start_value(self) -> str:
|
|
296
|
+
if self.start_now.get():
|
|
297
|
+
return "now"
|
|
298
|
+
date_text = self.start_date.get().strip()
|
|
299
|
+
hour = int(self.start_hour.get())
|
|
300
|
+
minute = int(self.start_minute.get())
|
|
301
|
+
return f"{date_text}T{hour:02d}:{minute:02d}:00"
|
|
302
|
+
|
|
303
|
+
def _sync_start_controls(self) -> None:
|
|
304
|
+
state = "disabled" if self.start_now.get() else "normal"
|
|
305
|
+
self.start_date_entry.configure(state=state)
|
|
306
|
+
self.start_hour_spin.configure(state=state)
|
|
307
|
+
self.start_minute_spin.configure(state=state)
|
|
308
|
+
|
|
309
|
+
def _poll_logs(self) -> None:
|
|
310
|
+
try:
|
|
311
|
+
while True:
|
|
312
|
+
message = self.log_queue.get_nowait()
|
|
313
|
+
if message == _DONE_MESSAGE:
|
|
314
|
+
self._set_running(False)
|
|
315
|
+
self.ffmpeg_activity.set("ffmpeg: idle")
|
|
316
|
+
self.comskip_activity.set("comskip: idle")
|
|
317
|
+
elif message.startswith(_ACTIVITY_PREFIX):
|
|
318
|
+
frame = message.removeprefix(_ACTIVITY_PREFIX)
|
|
319
|
+
self.ffmpeg_activity.set(f"ffmpeg: {frame}")
|
|
320
|
+
elif message.startswith(_COMSKIP_ACTIVITY_PREFIX):
|
|
321
|
+
frame = message.removeprefix(_COMSKIP_ACTIVITY_PREFIX)
|
|
322
|
+
self.comskip_activity.set(f"comskip: {frame}")
|
|
323
|
+
else:
|
|
324
|
+
self._log(message)
|
|
325
|
+
except queue.Empty:
|
|
326
|
+
pass
|
|
327
|
+
self.root.after(100, self._poll_logs)
|
|
328
|
+
|
|
329
|
+
def _log(self, message: str) -> None:
|
|
330
|
+
self.logs.configure(state="normal")
|
|
331
|
+
self.logs.insert(tk.END, message + "\n")
|
|
332
|
+
self.logs.see(tk.END)
|
|
333
|
+
self.logs.configure(state="disabled")
|
|
334
|
+
|
|
335
|
+
def _clear_logs(self) -> None:
|
|
336
|
+
self.logs.configure(state="normal")
|
|
337
|
+
self.logs.delete("1.0", tk.END)
|
|
338
|
+
self.logs.configure(state="disabled")
|
|
339
|
+
|
|
340
|
+
def _set_running(self, running: bool) -> None:
|
|
341
|
+
start_state = "disabled" if running else "normal"
|
|
342
|
+
stop_state = "normal" if running else "disabled"
|
|
343
|
+
self.record_button.configure(state=start_state)
|
|
344
|
+
self.comskip_button.configure(state=start_state)
|
|
345
|
+
self.stop_record_button.configure(state=stop_state)
|
|
346
|
+
self.stop_comskip_button.configure(state=stop_state)
|
|
347
|
+
|
|
348
|
+
def _request_stop(self) -> None:
|
|
349
|
+
if self.cancellation_event.is_set():
|
|
350
|
+
return
|
|
351
|
+
self.cancellation_event.set()
|
|
352
|
+
self.stop_record_button.configure(state="disabled")
|
|
353
|
+
self.stop_comskip_button.configure(state="disabled")
|
|
354
|
+
self._log("Stop requested.")
|
|
355
|
+
|
|
356
|
+
def _queue_ffmpeg_activity(self, frame: str) -> None:
|
|
357
|
+
self.log_queue.put(f"{_ACTIVITY_PREFIX}{frame}")
|
|
358
|
+
|
|
359
|
+
def _queue_comskip_activity(self, frame: str) -> None:
|
|
360
|
+
self.log_queue.put(f"{_COMSKIP_ACTIVITY_PREFIX}{frame}")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main() -> None:
|
|
364
|
+
root = tk.Tk()
|
|
365
|
+
RecorderApp(root)
|
|
366
|
+
root.mainloop()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _default_source_value(config: dict, values: list[str]) -> str:
|
|
370
|
+
default_key = config.get("default")
|
|
371
|
+
if isinstance(default_key, str):
|
|
372
|
+
prefix = f"{default_key} - "
|
|
373
|
+
match = next((value for value in values if value.startswith(prefix)), None)
|
|
374
|
+
if match:
|
|
375
|
+
return match
|
|
376
|
+
return values[0]
|
|
@@ -6,6 +6,7 @@ import subprocess
|
|
|
6
6
|
import sys
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
|
+
from collections.abc import Callable
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from datetime import datetime
|
|
11
12
|
from pathlib import Path
|
|
@@ -318,7 +319,13 @@ def _media_duration_seconds(ffmpeg: str, path: Path) -> float | None:
|
|
|
318
319
|
)
|
|
319
320
|
|
|
320
321
|
|
|
321
|
-
def run_recording(
|
|
322
|
+
def run_recording(
|
|
323
|
+
plan: RecordingPlan,
|
|
324
|
+
*,
|
|
325
|
+
debug: bool = False,
|
|
326
|
+
activity_callback: Callable[[str], None] | None = None,
|
|
327
|
+
cancellation_event: threading.Event | None = None,
|
|
328
|
+
) -> int:
|
|
322
329
|
plan.capture_path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
330
|
output = None if debug else subprocess.DEVNULL
|
|
324
331
|
stderr = None if debug else subprocess.PIPE
|
|
@@ -328,24 +335,25 @@ def run_recording(plan: RecordingPlan, *, debug: bool = False) -> int:
|
|
|
328
335
|
stdout=output,
|
|
329
336
|
stderr=stderr,
|
|
330
337
|
)
|
|
331
|
-
activity = _ActivityIndicator("ffmpeg activity")
|
|
338
|
+
activity = _ActivityIndicator("ffmpeg activity", callback=activity_callback)
|
|
332
339
|
activity.start(process.stderr if not debug else None)
|
|
333
340
|
try:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
341
|
+
deadline = time.monotonic() + plan.duration_seconds + 60
|
|
342
|
+
while True:
|
|
343
|
+
if cancellation_event and cancellation_event.is_set():
|
|
344
|
+
_stop_ffmpeg(process)
|
|
345
|
+
return 130
|
|
346
|
+
remaining = deadline - time.monotonic()
|
|
347
|
+
if remaining <= 0:
|
|
348
|
+
exit_code = _stop_ffmpeg(process)
|
|
349
|
+
break
|
|
350
|
+
try:
|
|
351
|
+
exit_code = process.wait(timeout=min(0.2, remaining))
|
|
352
|
+
break
|
|
353
|
+
except subprocess.TimeoutExpired:
|
|
354
|
+
continue
|
|
344
355
|
except KeyboardInterrupt:
|
|
345
|
-
|
|
346
|
-
process.stdin.write(b"q\n")
|
|
347
|
-
process.stdin.flush()
|
|
348
|
-
exit_code = process.wait()
|
|
356
|
+
exit_code = _stop_ffmpeg(process)
|
|
349
357
|
finally:
|
|
350
358
|
activity.stop()
|
|
351
359
|
|
|
@@ -359,13 +367,51 @@ def run_recording(plan: RecordingPlan, *, debug: bool = False) -> int:
|
|
|
359
367
|
plan.capture_path.replace(plan.output_path)
|
|
360
368
|
return _validate_duration(plan)
|
|
361
369
|
|
|
362
|
-
final =
|
|
370
|
+
final = _run_cancellable_command(
|
|
371
|
+
plan.final_command,
|
|
372
|
+
stdout=output,
|
|
373
|
+
stderr=output,
|
|
374
|
+
cancellation_event=cancellation_event,
|
|
375
|
+
)
|
|
363
376
|
if final.returncode == 0:
|
|
364
377
|
plan.capture_path.unlink(missing_ok=True)
|
|
365
378
|
return _validate_duration(plan)
|
|
366
379
|
return final.returncode
|
|
367
380
|
|
|
368
381
|
|
|
382
|
+
def _stop_ffmpeg(process: subprocess.Popen) -> int:
|
|
383
|
+
if process.stdin:
|
|
384
|
+
process.stdin.write(b"q\n")
|
|
385
|
+
process.stdin.flush()
|
|
386
|
+
try:
|
|
387
|
+
return process.wait(timeout=30)
|
|
388
|
+
except subprocess.TimeoutExpired:
|
|
389
|
+
process.terminate()
|
|
390
|
+
return process.wait()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _run_cancellable_command(
|
|
394
|
+
command: list[str],
|
|
395
|
+
*,
|
|
396
|
+
stdout,
|
|
397
|
+
stderr,
|
|
398
|
+
cancellation_event: threading.Event | None = None,
|
|
399
|
+
) -> subprocess.CompletedProcess:
|
|
400
|
+
if cancellation_event is None:
|
|
401
|
+
return subprocess.run(command, stdout=stdout, stderr=stderr)
|
|
402
|
+
|
|
403
|
+
process = subprocess.Popen(command, stdout=stdout, stderr=stderr)
|
|
404
|
+
while True:
|
|
405
|
+
if cancellation_event.is_set():
|
|
406
|
+
process.terminate()
|
|
407
|
+
return subprocess.CompletedProcess(command, process.wait(), None, None)
|
|
408
|
+
try:
|
|
409
|
+
return_code = process.wait(timeout=0.2)
|
|
410
|
+
return subprocess.CompletedProcess(command, return_code, None, None)
|
|
411
|
+
except subprocess.TimeoutExpired:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
|
|
369
415
|
def _validate_duration(plan: RecordingPlan) -> int:
|
|
370
416
|
duration = _media_duration_seconds(plan.ffmpeg_path, plan.output_path)
|
|
371
417
|
if duration is None:
|
|
@@ -381,8 +427,9 @@ def _validate_duration(plan: RecordingPlan) -> int:
|
|
|
381
427
|
|
|
382
428
|
|
|
383
429
|
class _ActivityIndicator:
|
|
384
|
-
def __init__(self, label: str) -> None:
|
|
430
|
+
def __init__(self, label: str, *, callback: Callable[[str], None] | None = None) -> None:
|
|
385
431
|
self.label = label
|
|
432
|
+
self.callback = callback
|
|
386
433
|
self._enabled = sys.stderr.isatty()
|
|
387
434
|
self._frames = "|/-\\"
|
|
388
435
|
self._index = 0
|
|
@@ -410,7 +457,7 @@ class _ActivityIndicator:
|
|
|
410
457
|
self._tick()
|
|
411
458
|
|
|
412
459
|
def _tick(self) -> None:
|
|
413
|
-
if not self._enabled:
|
|
460
|
+
if not self._enabled and not self.callback:
|
|
414
461
|
return
|
|
415
462
|
now = time.monotonic()
|
|
416
463
|
if now < self._next_update:
|
|
@@ -418,5 +465,8 @@ class _ActivityIndicator:
|
|
|
418
465
|
self._next_update = now + 0.15
|
|
419
466
|
frame = self._frames[self._index % len(self._frames)]
|
|
420
467
|
self._index += 1
|
|
421
|
-
|
|
422
|
-
|
|
468
|
+
if self.callback:
|
|
469
|
+
self.callback(frame)
|
|
470
|
+
if self._enabled:
|
|
471
|
+
sys.stderr.write(f"\r{self.label} {frame}")
|
|
472
|
+
sys.stderr.flush()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from tv_recorder.cli import _source_key_from_recording
|
|
6
|
+
from tv_recorder.cli import main
|
|
7
|
+
from tv_recorder.cli import run_record_command
|
|
8
|
+
from tv_recorder.recorder import _ActivityIndicator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_source_key_from_recording_uses_filename_prefix() -> None:
|
|
12
|
+
config = {
|
|
13
|
+
"sources": {
|
|
14
|
+
"tvaplus.ca": {},
|
|
15
|
+
"radio-canada.ca": {},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assert (
|
|
20
|
+
_source_key_from_recording(
|
|
21
|
+
config,
|
|
22
|
+
Path("recordings/tvaplus.ca-20260529-190942.mp4"),
|
|
23
|
+
)
|
|
24
|
+
== "tvaplus.ca"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_source_key_from_recording_uses_longest_match() -> None:
|
|
29
|
+
config = {
|
|
30
|
+
"sources": {
|
|
31
|
+
"globalnews": {},
|
|
32
|
+
"globalnews-national": {},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
assert (
|
|
37
|
+
_source_key_from_recording(
|
|
38
|
+
config,
|
|
39
|
+
Path("recordings/globalnews-national-20260529-190942.mp4"),
|
|
40
|
+
)
|
|
41
|
+
== "globalnews-national"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_help_shows_current_directory_as_default_output_dir() -> None:
|
|
46
|
+
from click.testing import CliRunner
|
|
47
|
+
|
|
48
|
+
result = CliRunner().invoke(main, ["--help"])
|
|
49
|
+
|
|
50
|
+
assert result.exit_code == 0
|
|
51
|
+
assert "current directory" in result.output
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_activity_indicator_calls_callback_when_stderr_is_not_tty() -> None:
|
|
55
|
+
frames = []
|
|
56
|
+
indicator = _ActivityIndicator("ffmpeg activity", callback=frames.append)
|
|
57
|
+
|
|
58
|
+
indicator._tick()
|
|
59
|
+
|
|
60
|
+
assert frames == ["|"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_record_command_honors_cancel_before_stream_detection() -> None:
|
|
64
|
+
cancelled = threading.Event()
|
|
65
|
+
cancelled.set()
|
|
66
|
+
config = {
|
|
67
|
+
"sources": {
|
|
68
|
+
"test": {
|
|
69
|
+
"display_name": "Test",
|
|
70
|
+
"start_url": "https://example.com",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
with patch("tv_recorder.cli.find_stream") as find_stream:
|
|
76
|
+
exit_code = run_record_command(
|
|
77
|
+
config,
|
|
78
|
+
source_key="test",
|
|
79
|
+
start_value="now",
|
|
80
|
+
duration_value="30m",
|
|
81
|
+
output_dir=Path("recordings"),
|
|
82
|
+
headful=False,
|
|
83
|
+
timeout_ms=45_000,
|
|
84
|
+
ffmpeg_path="ffmpeg",
|
|
85
|
+
comskip=False,
|
|
86
|
+
dry_run=False,
|
|
87
|
+
log_level="info",
|
|
88
|
+
cancellation_event=cancelled,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
assert exit_code == 130
|
|
92
|
+
find_stream.assert_not_called()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from io import BytesIO
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
import shutil
|
|
3
4
|
from uuid import uuid4
|
|
@@ -68,6 +69,38 @@ def test_run_comskip_accepts_completed_processing_with_nonzero_exit() -> None:
|
|
|
68
69
|
shutil.rmtree(work_dir, ignore_errors=True)
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def test_run_comskip_reports_activity_with_callback() -> None:
|
|
73
|
+
work_dir = Path("recordings") / f"tv-recorder-comskip-test-{uuid4().hex}"
|
|
74
|
+
work_dir.mkdir(parents=True)
|
|
75
|
+
try:
|
|
76
|
+
recording = work_dir / "show.mp4"
|
|
77
|
+
recording.write_bytes(b"video")
|
|
78
|
+
plan = ComskipPlan(
|
|
79
|
+
recording_path=recording,
|
|
80
|
+
edl_path=work_dir / "show.edl",
|
|
81
|
+
ini_path=work_dir / "show.comskip.ini",
|
|
82
|
+
commercial_free_path=work_dir / "show.commercial-free.mp4",
|
|
83
|
+
command=["comskip", str(recording)],
|
|
84
|
+
options={},
|
|
85
|
+
)
|
|
86
|
+
plan.edl_path.write_text("1.0\t2.0\t0\n", encoding="utf-8")
|
|
87
|
+
recording.with_suffix(".txt").write_text(
|
|
88
|
+
"FILE PROCESSING COMPLETE\n",
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
frames = []
|
|
92
|
+
|
|
93
|
+
with patch("tv_recorder.comskip.subprocess.Popen") as popen:
|
|
94
|
+
popen.return_value.stdout = BytesIO(b"activity")
|
|
95
|
+
popen.return_value.wait.return_value = 0
|
|
96
|
+
|
|
97
|
+
assert run_comskip(plan, activity_callback=frames.append) == 0
|
|
98
|
+
|
|
99
|
+
assert frames
|
|
100
|
+
finally:
|
|
101
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
102
|
+
|
|
103
|
+
|
|
71
104
|
def test_read_edl_cuts() -> None:
|
|
72
105
|
work_dir = Path("recordings") / f"tv-recorder-comskip-test-{uuid4().hex}"
|
|
73
106
|
work_dir.mkdir(parents=True)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from tv_recorder.gui import _default_source_value
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_default_source_value_uses_config_default() -> None:
|
|
5
|
+
values = [
|
|
6
|
+
"abc-news-live - ABC News Live",
|
|
7
|
+
"radio-canada.ca - ICI Radio-Canada Tele",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
assert (
|
|
11
|
+
_default_source_value({"default": "radio-canada.ca"}, values)
|
|
12
|
+
== "radio-canada.ca - ICI Radio-Canada Tele"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_default_source_value_falls_back_to_first_source() -> None:
|
|
17
|
+
values = [
|
|
18
|
+
"abc-news-live - ABC News Live",
|
|
19
|
+
"radio-canada.ca - ICI Radio-Canada Tele",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
assert _default_source_value({"default": "missing"}, values) == values[0]
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
)
|
{tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.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
|
|
File without changes
|