mvw-cli 0.1.1__tar.gz → 0.1.2__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 (29) hide show
  1. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/PKG-INFO +1 -1
  2. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/pyproject.toml +1 -1
  3. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/cli.py +35 -15
  4. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/download.py +35 -4
  5. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_cli.py +20 -1
  6. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_download.py +40 -1
  7. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/.gitignore +0 -0
  8. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/LICENSE +0 -0
  9. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/README.md +0 -0
  10. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/__init__.py +0 -0
  11. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/api.py +0 -0
  12. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/config.py +0 -0
  13. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/display.py +0 -0
  14. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/episodes.py +0 -0
  15. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/filters.py +0 -0
  16. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/models.py +0 -0
  17. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/naming.py +0 -0
  18. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/src/mvw/query.py +0 -0
  19. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/__init__.py +0 -0
  20. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_api.py +0 -0
  21. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_config.py +0 -0
  22. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_display.py +0 -0
  23. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_episodes.py +0 -0
  24. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_filters.py +0 -0
  25. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_live.py +0 -0
  26. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_models.py +0 -0
  27. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_naming.py +0 -0
  28. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_query.py +0 -0
  29. {mvw_cli-0.1.1 → mvw_cli-0.1.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mvw-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Search and download German public-broadcasting media from MediathekViewWeb, with Plex-friendly season downloads.
5
5
  Project-URL: Homepage, https://github.com/maxboettinger/mvw-cli
6
6
  Project-URL: Repository, https://github.com/maxboettinger/mvw-cli
@@ -3,7 +3,7 @@ name = "mvw-cli"
3
3
  description = "Search and download German public-broadcasting media from MediathekViewWeb, with Plex-friendly season downloads."
4
4
  readme = "README.md"
5
5
  requires-python = ">=3.13"
6
- version = "0.1.1"
6
+ version = "0.1.2"
7
7
  license = "MIT"
8
8
  license-files = ["LICENSE"]
9
9
  authors = [{ name = "Max Boettinger", email = "perplexity@bttngr.de" }]
@@ -5,10 +5,11 @@ from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
7
  import typer
8
- from rich.console import Console
8
+ from rich.console import Console, Group
9
+ from rich.live import Live
9
10
  from rich.progress import (
10
- BarColumn, DownloadColumn, Progress, SpinnerColumn,
11
- TextColumn, TransferSpeedColumn,
11
+ BarColumn, DownloadColumn, MofNCompleteColumn, Progress, SpinnerColumn,
12
+ TextColumn, TimeRemainingColumn, TransferSpeedColumn,
12
13
  )
13
14
 
14
15
  from mvw import config as configmod
@@ -151,29 +152,48 @@ def download(
151
152
  _run_downloads(plan, subtitles=subtitles)
152
153
 
153
154
 
154
- def _run_downloads(plan, *, subtitles: bool) -> None:
155
- progress = Progress(
155
+ def _make_progress() -> tuple[Progress, Progress, Group]:
156
+ """Build the download progress display.
157
+
158
+ Two separate ``Progress`` instances are used because a single ``Progress``
159
+ applies one column set to every task, yet the two task kinds carry different
160
+ units: the overall bar counts *files*, while per-file bars count *bytes*.
161
+ Rendering a file count through byte/speed columns produced the nonsensical
162
+ "10/10 bytes" / "0 bytes/s" overall display. They are stacked in a ``Group``
163
+ so both still render under a single ``Live``.
164
+ """
165
+ overall = Progress(
166
+ TextColumn("[progress.description]{task.description}"),
167
+ BarColumn(),
168
+ MofNCompleteColumn(), # "12/78", a count — no bogus byte unit
169
+ TimeRemainingColumn(),
170
+ )
171
+ per_file = Progress(
156
172
  SpinnerColumn(),
157
173
  TextColumn("[progress.description]{task.description}"),
158
174
  BarColumn(),
159
- DownloadColumn(),
175
+ DownloadColumn(), # bytes — correct here
160
176
  TransferSpeedColumn(),
161
- console=console,
162
177
  )
178
+ return overall, per_file, Group(overall, per_file)
179
+
180
+
181
+ def _run_downloads(plan, *, subtitles: bool) -> None:
182
+ overall_progress, file_progress, group = _make_progress()
163
183
  failures = 0
164
184
  ffmpeg_missing = False
165
- with progress:
166
- overall = progress.add_task("Overall", total=len(plan))
185
+ with Live(group, console=console):
186
+ overall = overall_progress.add_task("Overall", total=len(plan))
167
187
  for dest, url, _tier, sub_url in plan:
168
- task = progress.add_task(dest.name, total=None)
188
+ task = file_progress.add_task(dest.name, total=None)
169
189
 
170
190
  def cb(done: int, total, _t=task):
171
- progress.update(_t, completed=done, total=total)
191
+ file_progress.update(_t, completed=done, total=total)
172
192
 
173
193
  try:
174
194
  if is_hls(url):
175
- progress.update(task, description=f"{dest.name} (ffmpeg)")
176
- download_hls(url, dest)
195
+ file_progress.update(task, description=f"{dest.name} (ffmpeg)")
196
+ download_hls(url, dest, on_progress=cb)
177
197
  else:
178
198
  download_file(url, dest, on_progress=cb)
179
199
  if subtitles and sub_url:
@@ -187,8 +207,8 @@ def _run_downloads(plan, *, subtitles: bool) -> None:
187
207
  failures += 1
188
208
  err_console.print(display.error_panel(f"{dest.name}: {exc}"))
189
209
  finally:
190
- progress.update(task, visible=False)
191
- progress.advance(overall)
210
+ file_progress.update(task, visible=False)
211
+ overall_progress.advance(overall)
192
212
  if ffmpeg_missing:
193
213
  raise typer.Exit(4)
194
214
  elif failures:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import shutil
4
4
  import subprocess
5
+ import tempfile
6
+ import time
5
7
  from pathlib import Path
6
8
  from typing import Callable
7
9
 
@@ -79,14 +81,43 @@ def download(
79
81
  return dest
80
82
 
81
83
 
82
- def download_hls(url: str, dest: Path, *, ffmpeg: str = "ffmpeg") -> Path:
84
+ def download_hls(
85
+ url: str,
86
+ dest: Path,
87
+ *,
88
+ ffmpeg: str = "ffmpeg",
89
+ on_progress: ProgressCb | None = None,
90
+ poll_interval: float = 0.5,
91
+ ) -> Path:
83
92
  if shutil.which(ffmpeg) is None:
84
93
  raise FFmpegMissingError(
85
94
  "ffmpeg not found on PATH; required for HLS (.m3u8) downloads"
86
95
  )
87
96
  dest.parent.mkdir(parents=True, exist_ok=True)
88
97
  cmd = [ffmpeg, "-y", "-i", url, "-c", "copy", str(dest)]
89
- proc = subprocess.run(cmd, capture_output=True, text=True)
90
- if proc.returncode != 0:
91
- raise DownloadError(f"ffmpeg failed ({proc.returncode}): {proc.stderr[-300:]}")
98
+
99
+ def report() -> None:
100
+ # HLS has no known total, so report bytes written so far against an
101
+ # indeterminate total (None) to drive a live size/speed display.
102
+ if on_progress is not None:
103
+ try:
104
+ on_progress(dest.stat().st_size, None)
105
+ except OSError:
106
+ pass
107
+
108
+ # Stream ffmpeg's chatty stderr to a temp file rather than a PIPE we never
109
+ # read; an unread PIPE can fill its buffer and deadlock the child.
110
+ with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as errf:
111
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=errf)
112
+ try:
113
+ while proc.poll() is None:
114
+ report()
115
+ time.sleep(poll_interval)
116
+ finally:
117
+ proc.wait()
118
+ if proc.returncode != 0:
119
+ errf.seek(0)
120
+ tail = errf.read()[-300:]
121
+ raise DownloadError(f"ffmpeg failed ({proc.returncode}): {tail}")
122
+ report()
92
123
  return dest
@@ -55,7 +55,7 @@ def test_download_missing_ffmpeg_exits_4(monkeypatch, tmp_path):
55
55
  url_video_hd="https://cdn.example.com/stream_hd.m3u8")]
56
56
  monkeypatch.setattr(cli, "_make_client", lambda cfg: FakeClient(rows))
57
57
 
58
- def _fake_download_hls(url, dest):
58
+ def _fake_download_hls(url, dest, **kwargs):
59
59
  raise FFmpegMissingError("ffmpeg not found on PATH")
60
60
 
61
61
  monkeypatch.setattr(cli, "download_hls", _fake_download_hls)
@@ -127,3 +127,22 @@ def test_season_flag_gates_foldering(monkeypatch, tmp_path):
127
127
  assert result_flat.exit_code == 0
128
128
  assert "Season 07" not in result_flat.stdout
129
129
  assert "s07e05" in result_flat.stdout.lower()
130
+
131
+
132
+ def test_overall_progress_counts_files_not_bytes():
133
+ """Regression: the overall bar tracks a file count, so it must use a
134
+ count column (MofN) and never byte/speed columns, which produced the
135
+ bogus "10/10 bytes" / "0 bytes/s" display."""
136
+ from rich.progress import (
137
+ DownloadColumn, MofNCompleteColumn, TransferSpeedColumn,
138
+ )
139
+
140
+ overall, per_file, _ = cli._make_progress()
141
+
142
+ assert any(isinstance(c, MofNCompleteColumn) for c in overall.columns)
143
+ assert not any(
144
+ isinstance(c, (DownloadColumn, TransferSpeedColumn)) for c in overall.columns
145
+ )
146
+ # Per-file bars are byte-based and keep the byte/speed columns.
147
+ assert any(isinstance(c, DownloadColumn) for c in per_file.columns)
148
+ assert any(isinstance(c, TransferSpeedColumn) for c in per_file.columns)
@@ -4,7 +4,7 @@ import httpx
4
4
  import pytest
5
5
  import respx
6
6
 
7
- from mvw.download import DownloadError, download, is_hls, pick_resolution
7
+ from mvw.download import DownloadError, download, download_hls, is_hls, pick_resolution
8
8
  from mvw.models import MediathekResult
9
9
 
10
10
 
@@ -55,3 +55,42 @@ def test_download_resumes_with_range(tmp_path: Path):
55
55
  respx.get("https://v/m.mp4").mock(side_effect=handler)
56
56
  download("https://v/m.mp4", dest)
57
57
  assert dest.read_bytes() == b"hello world"
58
+
59
+
60
+ def _fake_ffmpeg(tmp_path: Path, *, sleep: float = 0.0, rc: int = 0) -> Path:
61
+ """A stand-in 'ffmpeg' that writes growing bytes to its output (last arg)."""
62
+ script = tmp_path / "fake_ffmpeg"
63
+ script.write_text(
64
+ "#!/bin/sh\n"
65
+ 'eval out="\\${$#}"\n'
66
+ f"sleep {sleep}\n"
67
+ 'printf "chunk-a" > "$out"\n'
68
+ 'printf "chunk-b" >> "$out"\n'
69
+ f"exit {rc}\n"
70
+ )
71
+ script.chmod(0o755)
72
+ return script
73
+
74
+
75
+ def test_download_hls_reports_progress_and_writes_output(tmp_path: Path):
76
+ ffmpeg = _fake_ffmpeg(tmp_path, sleep=0.6) # outlive one poll tick
77
+ dest = tmp_path / "out.mkv"
78
+ seen: list[tuple[int, object]] = []
79
+
80
+ result = download_hls(
81
+ "https://x/playlist.m3u8", dest, ffmpeg=str(ffmpeg),
82
+ on_progress=lambda d, t: seen.append((d, t)), poll_interval=0.1,
83
+ )
84
+
85
+ assert result == dest
86
+ assert dest.read_bytes() == b"chunk-achunk-b"
87
+ # HLS has no known total, so it is reported as indeterminate.
88
+ assert seen and all(t is None for _, t in seen)
89
+ # Final report reflects the fully-written file size.
90
+ assert seen[-1][0] == len(b"chunk-achunk-b")
91
+
92
+
93
+ def test_download_hls_raises_on_ffmpeg_failure(tmp_path: Path):
94
+ ffmpeg = _fake_ffmpeg(tmp_path, rc=1)
95
+ with pytest.raises(DownloadError, match="ffmpeg failed"):
96
+ download_hls("https://x/playlist.m3u8", tmp_path / "out.mkv", ffmpeg=str(ffmpeg))
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
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