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.
- tv_recorder/__init__.py +4 -0
- tv_recorder/cli.py +126 -0
- tv_recorder/config.py +59 -0
- tv_recorder/defaults.yaml +644 -0
- tv_recorder/duration.py +36 -0
- tv_recorder/recorder.py +422 -0
- tv_recorder/stream_finder.py +505 -0
- tv_recorder-0.0.0.dev2.dist-info/METADATA +186 -0
- tv_recorder-0.0.0.dev2.dist-info/RECORD +12 -0
- tv_recorder-0.0.0.dev2.dist-info/WHEEL +4 -0
- tv_recorder-0.0.0.dev2.dist-info/entry_points.txt +2 -0
- tv_recorder-0.0.0.dev2.dist-info/licenses/LICENSE +21 -0
tv_recorder/__init__.py
ADDED
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
|
+
)
|