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.
Files changed (26) hide show
  1. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/PKG-INFO +19 -3
  2. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/README.md +18 -2
  3. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/pyproject.toml +1 -1
  4. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/cli.py +51 -4
  5. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/comskip.py +103 -12
  6. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/defaults.yaml +1 -0
  7. tv_recorder-0.0.0.dev4/src/tv_recorder/gui.py +376 -0
  8. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/recorder.py +71 -21
  9. tv_recorder-0.0.0.dev4/tests/test_cli.py +92 -0
  10. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/tests/test_comskip.py +33 -0
  11. tv_recorder-0.0.0.dev4/tests/test_gui.py +22 -0
  12. tv_recorder-0.0.0.dev3/tests/test_cli.py +0 -37
  13. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/SKILL.md +0 -0
  14. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/agents/openai.yaml +0 -0
  15. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.codex/skills/tv-recorder-add-channel/references/channel-recipes.md +0 -0
  16. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.github/workflows/publish.yml +0 -0
  17. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/.gitignore +0 -0
  18. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/LICENSE +0 -0
  19. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/requirements.txt +0 -0
  20. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/scripts/check_version.py +0 -0
  21. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/__init__.py +0 -0
  22. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/config.py +0 -0
  23. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/duration.py +0 -0
  24. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/src/tv_recorder/stream_finder.py +0 -0
  25. {tv_recorder-0.0.0.dev3 → tv_recorder-0.0.0.dev4}/tests/test_duration.py +0 -0
  26. {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.dev3
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
- ## Local Installation
59
+ ## Installation
60
60
 
61
61
  ```powershell
62
- python -m pip install -e .
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
- ## Local Installation
5
+ ## Installation
6
6
 
7
7
  ```powershell
8
- python -m pip install -e .
8
+ pipx install tv-recorder
9
9
  ```
10
10
 
11
11
  Chromium is installed automatically on first use if Playwright does not already have it.
12
12
 
13
13
  The recorder uses the `ffmpeg` binary provided by `imageio-ffmpeg`.
14
14
 
15
+ For local development from a checkout:
16
+
17
+ ```powershell
18
+ pipx install --editable .
19
+ ```
20
+
15
21
  ## Usage
16
22
 
23
+ Start `tv-recorder` without arguments to open the native desktop GUI:
24
+
25
+ ```powershell
26
+ tv-recorder
27
+ ```
28
+
29
+ The GUI uses Python's built-in Tkinter toolkit and exposes the common recording
30
+ and Comskip options with a log panel for command output. Custom YAML
31
+ configuration remains available from the CLI.
32
+
17
33
  ```powershell
18
34
  tv-recorder radio-canada.ca now 2h
19
35
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tv-recorder"
7
- version = "0.0.0.dev3"
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"
@@ -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("recordings"), show_default=True, help="Output directory.")
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
- time.sleep(delay)
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(plan, debug=log_level == "debug")
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(comskip_plan, debug=log_level == "debug")
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(plan: ComskipPlan, *, debug: bool = False) -> int:
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
- output = None if debug else subprocess.DEVNULL
76
- completed = subprocess.run(plan.command, stdout=output, stderr=output)
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(plan: ComskipPlan, *, ffmpeg_path: str, debug: bool = False) -> int:
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 = subprocess.run(command, stdout=output, stderr=output)
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 = subprocess.run(command, stdout=output, stderr=output)
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(ffmpeg_path: str, input_path: Path, output_path: Path, *, debug: bool) -> int:
265
- output = None if debug else subprocess.DEVNULL
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 = subprocess.run(command, stdout=output, stderr=output)
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 = subprocess.run(command, stdout=output, stderr=output)
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)],
@@ -1,3 +1,4 @@
1
+ default: radio-canada.ca
1
2
  sources:
2
3
  radio-canada.ca:
3
4
  display_name: ICI Radio-Canada Tele
@@ -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(plan: RecordingPlan, *, debug: bool = False) -> int:
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
- exit_code = process.wait(timeout=plan.duration_seconds + 60)
335
- except subprocess.TimeoutExpired:
336
- if process.stdin:
337
- process.stdin.write(b"q\n")
338
- process.stdin.flush()
339
- try:
340
- exit_code = process.wait(timeout=30)
341
- except subprocess.TimeoutExpired:
342
- process.terminate()
343
- exit_code = process.wait()
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
- if process.stdin:
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 = subprocess.run(plan.final_command, stdout=output, stderr=output)
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
- sys.stderr.write(f"\r{self.label} {frame}")
422
- sys.stderr.flush()
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
- )