tv-recorder 0.0.0.dev2__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ __version__ = version("tv-recorder")
tv_recorder/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shlex
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from tv_recorder.config import get_source, load_config
10
+ from tv_recorder.duration import parse_duration, parse_start, seconds_until
11
+ from tv_recorder.recorder import build_ffmpeg_plan, build_output_path, run_recording
12
+ from tv_recorder.stream_finder import find_stream
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="tv-recorder",
18
+ description="Find an HLS stream with Playwright and record it with ffmpeg.",
19
+ )
20
+ parser.add_argument("source", nargs="?", help="Source defined in YAML, ex: radio-canada.ca")
21
+ parser.add_argument("start", nargs="?", help="'now' or local ISO date, ex: 2026-05-24T20:00:00")
22
+ parser.add_argument("duration", nargs="?", help="Duration, ex: 30m, 2h, 90s, 01:30:00")
23
+ parser.add_argument("--config", type=Path, help="Path to a YAML source config file.")
24
+ parser.add_argument("--list", action="store_true", help="List available sources.")
25
+ parser.add_argument("--output-dir", type=Path, default=Path("recordings"), help="Output directory.")
26
+ parser.add_argument("--headful", action="store_true", help="Show Chromium while detecting the stream.")
27
+ parser.add_argument("--timeout-ms", type=int, default=45_000, help="Playwright timeout in milliseconds.")
28
+ parser.add_argument("--ffmpeg", default="ffmpeg", help="Override the bundled imageio-ffmpeg binary.")
29
+ parser.add_argument("--dry-run", action="store_true", help="Detect the stream and print the ffmpeg command.")
30
+ log_group = parser.add_mutually_exclusive_group()
31
+ log_group.add_argument("--info", dest="log_level", action="store_const", const="info", default="info", help="Show normal progress and selected HLS URLs.")
32
+ log_group.add_argument("--debug", dest="log_level", action="store_const", const="debug", help="Show discovery steps and ffmpeg output.")
33
+ return parser
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = build_parser()
38
+ args = parser.parse_args(argv)
39
+
40
+ try:
41
+ config = load_config(args.config)
42
+ if args.list:
43
+ list_sources(config)
44
+ return 0
45
+
46
+ missing = [name for name in ("source", "start", "duration") if getattr(args, name) is None]
47
+ if missing:
48
+ parser.error("source, start, and duration are required unless --list is used")
49
+
50
+ source = get_source(config, args.source)
51
+
52
+ start = parse_start(args.start)
53
+ duration_seconds = parse_duration(args.duration)
54
+ delay = seconds_until(start)
55
+
56
+ if delay > 0:
57
+ _info(args, f"Waiting until {start.isoformat()} ({int(delay)} s).")
58
+ time.sleep(delay)
59
+
60
+ _info(args, f"Detecting stream for {source.display_name}...")
61
+ stream = find_stream(
62
+ source,
63
+ headless=not args.headful,
64
+ timeout_ms=args.timeout_ms,
65
+ debug_log=lambda message: _debug(args, message),
66
+ )
67
+ output_path = build_output_path(args.output_dir, source, start)
68
+ plan = build_ffmpeg_plan(
69
+ stream,
70
+ output_path,
71
+ duration_seconds,
72
+ ffmpeg_path=args.ffmpeg,
73
+ require_ffmpeg=not args.dry_run,
74
+ recording=source.recording,
75
+ )
76
+ _print_stream_urls(args, stream)
77
+
78
+ if args.dry_run:
79
+ print("ffmpeg command:")
80
+ print(shlex.join(plan.command))
81
+ return 0
82
+
83
+ _info(args, f"Recording to {plan.output_path}")
84
+ exit_code = run_recording(plan, debug=args.log_level == "debug")
85
+ if exit_code != 0:
86
+ print(f"ffmpeg exited with code {exit_code}.", file=sys.stderr)
87
+ return exit_code
88
+ except Exception as exc:
89
+ print(f"Error: {exc}", file=sys.stderr)
90
+ return 1
91
+
92
+
93
+ def list_sources(config: dict) -> None:
94
+ sources = config.get("sources") or {}
95
+ if not sources:
96
+ print("No sources available.")
97
+ return
98
+
99
+ width = max(len(key) for key in sources)
100
+ for key in sorted(sources):
101
+ display_name = sources[key].get("display_name") or key
102
+ print(f"{key.ljust(width)} {display_name}")
103
+
104
+
105
+ def _info(args, message: str) -> None:
106
+ if args.log_level in {"info", "debug"}:
107
+ print(message, flush=True)
108
+
109
+
110
+ def _debug(args, message: str) -> None:
111
+ if args.log_level == "debug":
112
+ print(f"DEBUG {message}", flush=True)
113
+
114
+
115
+ def _print_stream_urls(args, stream) -> None:
116
+ discovered = stream.discovered_url or stream.url
117
+ _debug(args, f"Discovered HLS URL: {discovered}")
118
+ if stream.input_urls:
119
+ for index, input_url in enumerate(stream.input_urls, start=1):
120
+ _info(args, f"Recording HLS input {index}: {input_url}")
121
+ return
122
+ _info(args, f"Recording HLS URL: {stream.url}")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
tv_recorder/config.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SourceConfig:
13
+ key: str
14
+ display_name: str
15
+ start_url: str
16
+ stream_url_pattern: str = r"\.m3u8(\?|$)"
17
+ stream_request_urls: tuple[str, ...] = ()
18
+ stream_response_url_patterns: tuple[str, ...] = ()
19
+ stream_response_json_keys: tuple[str, ...] = ()
20
+ stream_url_reject_patterns: tuple[str, ...] = ()
21
+ output_extension: str = "mp4"
22
+ recording: dict[str, Any] | None = None
23
+ steps: tuple[dict[str, Any], ...] = ()
24
+ user_agent: str | None = None
25
+
26
+
27
+ def load_config(path: Path | None = None) -> dict[str, Any]:
28
+ if path:
29
+ with path.open("r", encoding="utf-8") as handle:
30
+ return yaml.safe_load(handle) or {}
31
+
32
+ default_text = resources.files("tv_recorder").joinpath("defaults.yaml").read_text(encoding="utf-8")
33
+ return yaml.safe_load(default_text) or {}
34
+
35
+
36
+ def get_source(config: dict[str, Any], source_key: str) -> SourceConfig:
37
+ sources = config.get("sources") or {}
38
+ raw = sources.get(source_key)
39
+ if not raw:
40
+ available = ", ".join(sorted(sources)) or "none"
41
+ raise KeyError(f"Unknown source: {source_key}. Available sources: {available}.")
42
+
43
+ if not raw.get("start_url"):
44
+ raise ValueError(f"Source {source_key} must define start_url.")
45
+
46
+ return SourceConfig(
47
+ key=source_key,
48
+ display_name=raw.get("display_name") or source_key,
49
+ start_url=raw["start_url"],
50
+ stream_url_pattern=raw.get("stream_url_pattern") or r"\.m3u8(\?|$)",
51
+ stream_request_urls=tuple(raw.get("stream_request_urls") or ()),
52
+ stream_response_url_patterns=tuple(raw.get("stream_response_url_patterns") or ()),
53
+ stream_response_json_keys=tuple(raw.get("stream_response_json_keys") or ()),
54
+ stream_url_reject_patterns=tuple(raw.get("stream_url_reject_patterns") or ()),
55
+ output_extension=raw.get("output_extension") or "mp4",
56
+ recording=raw.get("recording"),
57
+ steps=tuple(raw.get("steps") or ()),
58
+ user_agent=raw.get("user_agent"),
59
+ )