talks-reducer 0.5.5__tar.gz → 0.6.1__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 (38) hide show
  1. {talks_reducer-0.5.5/talks_reducer.egg-info → talks_reducer-0.6.1}/PKG-INFO +18 -7
  2. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/README.md +17 -6
  3. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/__about__.py +1 -1
  4. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/cli.py +194 -33
  5. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/discovery.py +42 -12
  6. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/gui.py +694 -168
  7. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/pipeline.py +1 -1
  8. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/server.py +110 -23
  9. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/server_tray.py +230 -18
  10. talks_reducer-0.6.1/talks_reducer/service_client.py +356 -0
  11. talks_reducer-0.6.1/talks_reducer/version_utils.py +22 -0
  12. {talks_reducer-0.5.5 → talks_reducer-0.6.1/talks_reducer.egg-info}/PKG-INFO +18 -7
  13. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer.egg-info/SOURCES.txt +2 -0
  14. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/tests/test_cli.py +119 -8
  15. talks_reducer-0.6.1/tests/test_discovery.py +119 -0
  16. talks_reducer-0.6.1/tests/test_gui_summary_parsing.py +50 -0
  17. talks_reducer-0.6.1/tests/test_server.py +181 -0
  18. talks_reducer-0.6.1/tests/test_service_client.py +238 -0
  19. talks_reducer-0.5.5/talks_reducer/service_client.py +0 -102
  20. talks_reducer-0.5.5/tests/test_discovery.py +0 -52
  21. talks_reducer-0.5.5/tests/test_server.py +0 -68
  22. talks_reducer-0.5.5/tests/test_service_client.py +0 -112
  23. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/LICENSE +0 -0
  24. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/pyproject.toml +0 -0
  25. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/setup.cfg +0 -0
  26. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/__init__.py +0 -0
  27. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/__main__.py +0 -0
  28. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/audio.py +0 -0
  29. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/chunks.py +0 -0
  30. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/ffmpeg.py +0 -0
  31. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/models.py +0 -0
  32. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer/progress.py +0 -0
  33. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer.egg-info/dependency_links.txt +0 -0
  34. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer.egg-info/entry_points.txt +0 -0
  35. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer.egg-info/requires.txt +0 -0
  36. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/talks_reducer.egg-info/top_level.txt +0 -0
  37. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/tests/test_audio.py +0 -0
  38. {talks_reducer-0.5.5 → talks_reducer-0.6.1}/tests/test_pipeline_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talks-reducer
3
- Version: 0.5.5
3
+ Version: 0.6.1
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -72,12 +72,17 @@ talks-reducer --small input.mp4
72
72
 
73
73
  Need to offload work to a remote Talks Reducer server? Pass `--url` with the
74
74
  server address and the CLI will upload the input, wait for processing to finish,
75
- and download the rendered video:
75
+ and download the rendered video. You can also provide `--host` to expand to the
76
+ default Talks Reducer port (`http://<host>:9005`):
76
77
 
77
78
  ```sh
78
79
  talks-reducer --url http://localhost:9005 demo.mp4
80
+ talks-reducer --host 192.168.1.42 demo.mp4
79
81
  ```
80
82
 
83
+ Want to see progress as the remote server works? Add `--server-stream` so the
84
+ CLI prints live progress bars and log lines while you wait for the download.
85
+
81
86
  ### Speech detection
82
87
 
83
88
  Talks Reducer now relies on its built-in volume thresholding to detect speech. Adjust `--silent_threshold` if you need to fine-tune when segments count as silence. Dropping the optional Silero VAD integration keeps the install lightweight and avoids pulling in PyTorch.
@@ -106,7 +111,8 @@ desktop shortcut without opening the GUI first.
106
111
  Pass `--debug` to print verbose logs about the tray icon lifecycle, and
107
112
  `--tray-mode pystray-detached` to try pystray's alternate detached runner. If
108
113
  the icon backend refuses to appear, fall back to `--tray-mode headless` to keep
109
- the web server running without a tray process. The tray menu includes an **Open GUI**
114
+ the web server running without a tray process. The tray menu highlights the
115
+ running Talks Reducer version and includes an **Open GUI**
110
116
  item (also triggered by double-clicking the icon) that launches the desktop
111
117
  Talks Reducer interface alongside an **Open WebUI** entry that opens the Gradio
112
118
  page in your browser. Close the GUI window to return to the tray without
@@ -120,13 +126,18 @@ browser automatically—pass `--open-browser` if you prefer the web page to
120
126
  launch as soon as the server is ready.
121
127
 
122
128
  This opens a local web page featuring a drag-and-drop upload zone, a **Small video** checkbox that mirrors the CLI preset, a live
123
- progress indicator, and automatic previews of the processed output. Once the job completes you can inspect the resulting compression
124
- ratio and download the rendered video directly from the page.
129
+ progress indicator, and automatic previews of the processed output. The page header and browser tab title include the current
130
+ Talks Reducer version so you can confirm which build the server is running. Once the job completes you can inspect the resulting
131
+ compression ratio and download the rendered video directly from the page.
125
132
 
126
133
  The desktop GUI mirrors this behaviour. Open **Advanced** settings to provide a
127
134
  server URL and click **Discover** to scan your local network for Talks Reducer
128
- instances listening on port `9005`. Leaving the field blank keeps processing
129
- local; selecting a discovered server delegates rendering to that machine while
135
+ instances listening on port `9005`. The button now updates with the discovery
136
+ progress, showing the scanned/total host count as `scanned / total`. A new
137
+ **Processing mode** toggle lets you decide whether work stays local or uploads
138
+ to the configured server—the **Remote** option becomes available as soon as a
139
+ URL is supplied. Leave the toggle on **Local** to keep rendering on this
140
+ machine even if a server is saved; switch to **Remote** to hand jobs off while
130
141
  the GUI downloads the finished files automatically.
131
142
 
132
143
  ### Uploading and retrieving a processed video
@@ -44,12 +44,17 @@ talks-reducer --small input.mp4
44
44
 
45
45
  Need to offload work to a remote Talks Reducer server? Pass `--url` with the
46
46
  server address and the CLI will upload the input, wait for processing to finish,
47
- and download the rendered video:
47
+ and download the rendered video. You can also provide `--host` to expand to the
48
+ default Talks Reducer port (`http://<host>:9005`):
48
49
 
49
50
  ```sh
50
51
  talks-reducer --url http://localhost:9005 demo.mp4
52
+ talks-reducer --host 192.168.1.42 demo.mp4
51
53
  ```
52
54
 
55
+ Want to see progress as the remote server works? Add `--server-stream` so the
56
+ CLI prints live progress bars and log lines while you wait for the download.
57
+
53
58
  ### Speech detection
54
59
 
55
60
  Talks Reducer now relies on its built-in volume thresholding to detect speech. Adjust `--silent_threshold` if you need to fine-tune when segments count as silence. Dropping the optional Silero VAD integration keeps the install lightweight and avoids pulling in PyTorch.
@@ -78,7 +83,8 @@ desktop shortcut without opening the GUI first.
78
83
  Pass `--debug` to print verbose logs about the tray icon lifecycle, and
79
84
  `--tray-mode pystray-detached` to try pystray's alternate detached runner. If
80
85
  the icon backend refuses to appear, fall back to `--tray-mode headless` to keep
81
- the web server running without a tray process. The tray menu includes an **Open GUI**
86
+ the web server running without a tray process. The tray menu highlights the
87
+ running Talks Reducer version and includes an **Open GUI**
82
88
  item (also triggered by double-clicking the icon) that launches the desktop
83
89
  Talks Reducer interface alongside an **Open WebUI** entry that opens the Gradio
84
90
  page in your browser. Close the GUI window to return to the tray without
@@ -92,13 +98,18 @@ browser automatically—pass `--open-browser` if you prefer the web page to
92
98
  launch as soon as the server is ready.
93
99
 
94
100
  This opens a local web page featuring a drag-and-drop upload zone, a **Small video** checkbox that mirrors the CLI preset, a live
95
- progress indicator, and automatic previews of the processed output. Once the job completes you can inspect the resulting compression
96
- ratio and download the rendered video directly from the page.
101
+ progress indicator, and automatic previews of the processed output. The page header and browser tab title include the current
102
+ Talks Reducer version so you can confirm which build the server is running. Once the job completes you can inspect the resulting
103
+ compression ratio and download the rendered video directly from the page.
97
104
 
98
105
  The desktop GUI mirrors this behaviour. Open **Advanced** settings to provide a
99
106
  server URL and click **Discover** to scan your local network for Talks Reducer
100
- instances listening on port `9005`. Leaving the field blank keeps processing
101
- local; selecting a discovered server delegates rendering to that machine while
107
+ instances listening on port `9005`. The button now updates with the discovery
108
+ progress, showing the scanned/total host count as `scanned / total`. A new
109
+ **Processing mode** toggle lets you decide whether work stays local or uploads
110
+ to the configured server—the **Remote** option becomes available as soon as a
111
+ URL is supplied. Leave the toggle on **Local** to keep rendering on this
112
+ machine even if a server is saved; switch to **Remote** to hand jobs off while
102
113
  the GUI downloads the finished files automatically.
103
114
 
104
115
  ### Uploading and retrieving a processed video
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.5.5"
5
+ __version__ = "0.6.1"
@@ -4,23 +4,21 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import os
7
+ import shutil
8
+ import subprocess
7
9
  import sys
8
10
  import time
9
11
  from importlib import import_module
10
- from importlib.metadata import PackageNotFoundError, version
11
12
  from pathlib import Path
12
- from typing import Dict, List, Optional, Sequence
13
+ from typing import Dict, List, Optional, Sequence, Tuple
13
14
 
14
15
  from . import audio
15
16
 
16
- try:
17
- from .__about__ import __version__ as _about_version
18
- except Exception: # pragma: no cover - fallback if metadata file missing
19
- _about_version = ""
20
17
  from .ffmpeg import FFmpegNotFoundError
21
18
  from .models import ProcessingOptions, default_temp_folder
22
19
  from .pipeline import speed_up_video
23
20
  from .progress import TqdmProgressReporter
21
+ from .version_utils import resolve_version
24
22
 
25
23
 
26
24
  def _build_parser() -> argparse.ArgumentParser:
@@ -31,7 +29,7 @@ def _build_parser() -> argparse.ArgumentParser:
31
29
  )
32
30
 
33
31
  # Add version argument
34
- pkg_version = _resolve_version()
32
+ pkg_version = resolve_version()
35
33
 
36
34
  parser.add_argument(
37
35
  "--version",
@@ -104,21 +102,20 @@ def _build_parser() -> argparse.ArgumentParser:
104
102
  default=None,
105
103
  help="Process videos via a Talks Reducer server at the provided base URL (for example, http://localhost:9005).",
106
104
  )
105
+ parser.add_argument(
106
+ "--host",
107
+ dest="host",
108
+ default=None,
109
+ help="Shortcut for --url when targeting a Talks Reducer server on port 9005 (for example, localhost).",
110
+ )
111
+ parser.add_argument(
112
+ "--server-stream",
113
+ action="store_true",
114
+ help="Stream remote progress updates when using --url.",
115
+ )
107
116
  return parser
108
117
 
109
118
 
110
- def _resolve_version() -> str:
111
- """Determine the package version for CLI reporting."""
112
-
113
- if _about_version:
114
- return _about_version
115
-
116
- try:
117
- return version("talks-reducer")
118
- except (PackageNotFoundError, Exception):
119
- return "unknown"
120
-
121
-
122
119
  def gather_input_files(paths: List[str]) -> List[str]:
123
120
  """Expand provided paths into a flat list of files that contain audio streams."""
124
121
 
@@ -146,21 +143,22 @@ def _print_total_time(start_time: float) -> None:
146
143
 
147
144
  def _process_via_server(
148
145
  files: Sequence[str], parsed_args: argparse.Namespace, *, start_time: float
149
- ) -> None:
150
- """Upload *files* to the configured server and download the results."""
146
+ ) -> Tuple[bool, Optional[str]]:
147
+ """Upload *files* to the configured server and download the results.
148
+
149
+ Returns a tuple of (success, error_message). When *success* is ``False``,
150
+ ``error_message`` contains the reason and the caller should fall back to the
151
+ local processing pipeline.
152
+ """
151
153
 
152
154
  try:
153
155
  from . import service_client
154
156
  except ImportError as exc: # pragma: no cover - optional dependency guard
155
- print(
156
- "Server mode requires the gradio_client dependency." f" ({exc})",
157
- file=sys.stderr,
158
- )
159
- sys.exit(1)
157
+ return False, ("Server mode requires the gradio_client dependency. " f"({exc})")
160
158
 
161
159
  server_url = parsed_args.server_url
162
160
  if not server_url:
163
- return
161
+ return False, "Server URL was not provided."
164
162
 
165
163
  output_override: Optional[Path] = None
166
164
  if parsed_args.output_file and len(files) == 1:
@@ -195,23 +193,58 @@ def _process_via_server(
195
193
  print(
196
194
  f"Processing file {index}/{len(files)} '{basename}' via server {server_url}"
197
195
  )
196
+ printed_log_header = False
197
+ progress_state: dict[str, tuple[Optional[int], Optional[int], str]] = {}
198
+ stream_updates = bool(getattr(parsed_args, "server_stream", False))
199
+
200
+ def _stream_server_log(line: str) -> None:
201
+ nonlocal printed_log_header
202
+ if not printed_log_header:
203
+ print("\nServer log:", flush=True)
204
+ printed_log_header = True
205
+ print(line, flush=True)
206
+
207
+ def _stream_progress(
208
+ desc: str, current: Optional[int], total: Optional[int], unit: str
209
+ ) -> None:
210
+ key = desc or "Processing"
211
+ state = (current, total, unit)
212
+ if progress_state.get(key) == state:
213
+ return
214
+ progress_state[key] = state
215
+
216
+ parts: list[str] = []
217
+ if current is not None and total and total > 0:
218
+ percent = (current / total) * 100
219
+ parts.append(f"{current}/{total}")
220
+ parts.append(f"{percent:.1f}%")
221
+ elif current is not None:
222
+ parts.append(str(current))
223
+ if unit:
224
+ parts.append(unit)
225
+ message = " ".join(parts).strip()
226
+ print(f"{key}: {message or 'update'}", flush=True)
227
+
198
228
  try:
199
229
  destination, summary, log_text = service_client.send_video(
200
230
  input_path=Path(file),
201
231
  output_path=output_override,
202
232
  server_url=server_url,
203
233
  small=bool(parsed_args.small),
234
+ log_callback=_stream_server_log,
235
+ stream_updates=stream_updates,
236
+ progress_callback=_stream_progress if stream_updates else None,
204
237
  )
205
238
  except Exception as exc: # pragma: no cover - network failure safeguard
206
- print(f"Failed to process {basename}: {exc}", file=sys.stderr)
207
- sys.exit(1)
239
+ return False, f"Failed to process {basename} via server: {exc}"
208
240
 
209
241
  print(summary)
210
242
  print(f"Saved processed video to {destination}")
211
- if log_text.strip():
243
+ if log_text.strip() and not printed_log_header:
212
244
  print("\nServer log:\n" + log_text)
213
245
 
214
246
  _print_total_time(start_time)
247
+ return True, None
215
248
 
216
249
 
217
250
  def _launch_gui(argv: Sequence[str]) -> bool:
@@ -230,7 +263,7 @@ def _launch_gui(argv: Sequence[str]) -> bool:
230
263
 
231
264
 
232
265
  def _launch_server(argv: Sequence[str]) -> bool:
233
- """Attempt to launch the Gradio web server with the provided arguments."""
266
+ """Attempt to launch the Gradio server with the provided arguments."""
234
267
 
235
268
  try:
236
269
  server_module = import_module(".server", __package__)
@@ -245,6 +278,103 @@ def _launch_server(argv: Sequence[str]) -> bool:
245
278
  return True
246
279
 
247
280
 
281
+ def _find_server_tray_binary() -> Optional[Path]:
282
+ """Return the best available path to the server tray executable."""
283
+
284
+ binary_name = "talks-reducer-server-tray"
285
+ candidates: List[Path] = []
286
+
287
+ which_path = shutil.which(binary_name)
288
+ if which_path:
289
+ candidates.append(Path(which_path))
290
+
291
+ try:
292
+ launcher_dir = Path(sys.argv[0]).resolve().parent
293
+ except Exception:
294
+ launcher_dir = None
295
+
296
+ potential_names = [binary_name]
297
+ if sys.platform == "win32":
298
+ potential_names = [f"{binary_name}.exe", binary_name]
299
+
300
+ if launcher_dir is not None:
301
+ for name in potential_names:
302
+ candidates.append(launcher_dir / name)
303
+
304
+ for candidate in candidates:
305
+ if candidate and candidate.exists() and os.access(candidate, os.X_OK):
306
+ return candidate
307
+
308
+ return None
309
+
310
+
311
+ def _should_hide_subprocess_console() -> bool:
312
+ """Return ``True` ` when a detached Windows launch should hide the console."""
313
+
314
+ if sys.platform != "win32":
315
+ return False
316
+
317
+ try:
318
+ import ctypes
319
+ except Exception: # pragma: no cover - optional runtime dependency
320
+ return False
321
+
322
+ try:
323
+ get_console_window = ctypes.windll.kernel32.GetConsoleWindow # type: ignore[attr-defined]
324
+ except Exception: # pragma: no cover - platform specific guard
325
+ return False
326
+
327
+ try:
328
+ handle = get_console_window()
329
+ except Exception: # pragma: no cover - defensive fallback
330
+ return False
331
+
332
+ return handle == 0
333
+
334
+
335
+ def _launch_server_tray_binary(argv: Sequence[str]) -> bool:
336
+ """Launch the packaged server tray executable when available."""
337
+
338
+ command = _find_server_tray_binary()
339
+ if command is None:
340
+ return False
341
+
342
+ tray_args = [str(command), *list(argv)]
343
+
344
+ run_kwargs: Dict[str, object] = {"check": False}
345
+
346
+ if sys.platform == "win32":
347
+ no_window_flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
348
+ if no_window_flag and _should_hide_subprocess_console():
349
+ run_kwargs["creationflags"] = no_window_flag
350
+
351
+ try:
352
+ result = subprocess.run(tray_args, **run_kwargs)
353
+ except OSError:
354
+ return False
355
+
356
+ return result.returncode == 0
357
+
358
+
359
+ def _launch_server_tray(argv: Sequence[str]) -> bool:
360
+ """Attempt to launch the server tray helper with the provided arguments."""
361
+
362
+ if _launch_server_tray_binary(argv):
363
+ return True
364
+
365
+ try:
366
+ tray_module = import_module(".server_tray", __package__)
367
+ except ImportError:
368
+ return False
369
+
370
+ tray_main = getattr(tray_module, "main", None)
371
+ if tray_main is None:
372
+ return False
373
+
374
+ tray_main(list(argv))
375
+ return True
376
+
377
+
248
378
  def main(argv: Optional[Sequence[str]] = None) -> None:
249
379
  """Entry point for the command line interface.
250
380
 
@@ -256,6 +386,14 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
256
386
  else:
257
387
  argv_list = list(argv)
258
388
 
389
+ if "--server" in argv_list:
390
+ index = argv_list.index("--server")
391
+ tray_args = argv_list[index + 1 :]
392
+ if not _launch_server_tray(tray_args):
393
+ print("Server tray mode is unavailable.", file=sys.stderr)
394
+ sys.exit(1)
395
+ return
396
+
259
397
  if argv_list and argv_list[0] in {"server", "serve"}:
260
398
  if not _launch_server(argv_list[1:]):
261
399
  print("Gradio server mode is unavailable.", file=sys.stderr)
@@ -272,6 +410,11 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
272
410
 
273
411
  parser = _build_parser()
274
412
  parsed_args = parser.parse_args(argv_list)
413
+
414
+ host_value = getattr(parsed_args, "host", None)
415
+ if host_value:
416
+ parsed_args.server_url = f"http://{host_value}:9005"
417
+
275
418
  start_time = time.time()
276
419
 
277
420
  files = gather_input_files(parsed_args.input_file)
@@ -281,15 +424,33 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
281
424
  }
282
425
  del args["input_file"]
283
426
 
427
+ if "host" in args:
428
+ del args["host"]
429
+
284
430
  if len(files) > 1 and "output_file" in args:
285
431
  del args["output_file"]
286
432
 
433
+ fallback_messages: List[str] = []
287
434
  if parsed_args.server_url:
288
- _process_via_server(files, parsed_args, start_time=start_time)
289
- return
435
+ server_success, error_message = _process_via_server(
436
+ files, parsed_args, start_time=start_time
437
+ )
438
+ if server_success:
439
+ return
440
+
441
+ fallback_reason = error_message or "Server processing is unavailable."
442
+ print(fallback_reason, file=sys.stderr)
443
+ fallback_messages.append(fallback_reason)
444
+
445
+ fallback_notice = "Falling back to local processing pipeline."
446
+ print(fallback_notice, file=sys.stderr)
447
+ fallback_messages.append(fallback_notice)
290
448
 
291
449
  reporter = TqdmProgressReporter()
292
450
 
451
+ for message in fallback_messages:
452
+ reporter.log(message)
453
+
293
454
  for index, file in enumerate(files):
294
455
  print(f"Processing file {index + 1}/{len(files)} '{os.path.basename(file)}'")
295
456
  local_options = dict(args)
@@ -7,11 +7,21 @@ import socket
7
7
  from concurrent.futures import ThreadPoolExecutor
8
8
  from contextlib import closing
9
9
  from http.client import HTTPConnection
10
- from typing import Iterable, Iterator, List, Optional, Set
10
+ from typing import Callable, Iterable, Iterator, List, Optional, Set
11
11
 
12
12
  DEFAULT_PORT = 9005
13
13
  DEFAULT_TIMEOUT = 0.4
14
14
 
15
+ _EXCLUDED_HOSTS = {"127.0.0.1", "localhost", "0.0.0.0"}
16
+
17
+
18
+ def _should_include_host(host: Optional[str]) -> bool:
19
+ """Return ``True`` when *host* should be scanned for discovery."""
20
+
21
+ if not host:
22
+ return False
23
+ return host not in _EXCLUDED_HOSTS
24
+
15
25
 
16
26
  def _iter_local_ipv4_addresses() -> Iterator[str]:
17
27
  """Yield IPv4 addresses that belong to the local machine."""
@@ -43,16 +53,19 @@ def _iter_local_ipv4_addresses() -> Iterator[str]:
43
53
  def _build_default_host_candidates(prefix_length: int = 24) -> List[str]:
44
54
  """Return a list of host candidates based on detected local networks."""
45
55
 
46
- hosts: Set[str] = {"127.0.0.1", "localhost"}
56
+ hosts: Set[str] = set()
47
57
 
48
58
  for address in _iter_local_ipv4_addresses():
49
- hosts.add(address)
59
+ if _should_include_host(address):
60
+ hosts.add(address)
50
61
  try:
51
62
  network = ipaddress.ip_network(f"{address}/{prefix_length}", strict=False)
52
63
  except ValueError:
53
64
  continue
54
65
  for host in network.hosts():
55
- hosts.add(str(host))
66
+ host_str = str(host)
67
+ if _should_include_host(host_str):
68
+ hosts.add(host_str)
56
69
 
57
70
  return sorted(hosts)
58
71
 
@@ -82,36 +95,53 @@ def _probe_host(host: str, port: int, timeout: float) -> Optional[str]:
82
95
  return None
83
96
 
84
97
 
98
+ ProgressCallback = Callable[[int, int], None]
99
+
100
+
85
101
  def discover_servers(
86
102
  *,
87
103
  port: int = DEFAULT_PORT,
88
104
  timeout: float = DEFAULT_TIMEOUT,
89
105
  hosts: Optional[Iterable[str]] = None,
106
+ progress_callback: Optional[ProgressCallback] = None,
90
107
  ) -> List[str]:
91
108
  """Scan *hosts* for running Talks Reducer servers on *port*.
92
109
 
93
110
  When *hosts* is omitted, the local /24 networks derived from available IPv4
94
- addresses are scanned. ``localhost`` and ``127.0.0.1`` are always included.
95
- The function returns a sorted list of unique base URLs.
111
+ addresses are scanned. ``127.0.0.1``, ``localhost``, and ``0.0.0.0`` are
112
+ excluded to avoid duplicating local endpoints. The optional
113
+ *progress_callback* receives the number of scanned hosts and the total
114
+ candidate count whenever discovery advances. The function returns a sorted
115
+ list of unique base URLs.
96
116
  """
97
117
 
98
118
  if hosts is None:
99
- candidates = _build_default_host_candidates()
119
+ candidates = sorted(
120
+ {
121
+ host
122
+ for host in _build_default_host_candidates()
123
+ if _should_include_host(host)
124
+ }
125
+ )
100
126
  else:
101
- candidates = sorted(set(hosts))
102
- if "127.0.0.1" not in candidates:
103
- candidates.append("127.0.0.1")
104
- if "localhost" not in candidates:
105
- candidates.append("localhost")
127
+ candidates = sorted({host for host in hosts if _should_include_host(host)})
106
128
 
107
129
  results: List[str] = []
130
+ total = len(candidates)
131
+
132
+ if progress_callback is not None:
133
+ progress_callback(0, total)
108
134
 
109
135
  with ThreadPoolExecutor(max_workers=32) as executor:
136
+ scanned = 0
110
137
  for url in executor.map(
111
138
  lambda host: _probe_host(host, port, timeout), candidates
112
139
  ):
113
140
  if url and url not in results:
114
141
  results.append(url)
142
+ scanned += 1
143
+ if progress_callback is not None:
144
+ progress_callback(scanned, total)
115
145
 
116
146
  return results
117
147