mvw-cli 0.1.0__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.
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/PKG-INFO +17 -1
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/README.md +13 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/pyproject.toml +7 -9
- mvw_cli-0.1.2/src/mvw/__init__.py +6 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/cli.py +35 -15
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/download.py +35 -4
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_cli.py +20 -1
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_download.py +40 -1
- mvw_cli-0.1.0/src/mvw/__init__.py +0 -1
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/.gitignore +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/LICENSE +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/api.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/config.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/display.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/episodes.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/filters.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/models.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/naming.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/src/mvw/query.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/__init__.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_api.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_config.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_display.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_episodes.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_filters.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_live.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_models.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_naming.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_query.py +0 -0
- {mvw_cli-0.1.0 → mvw_cli-0.1.2}/tests/test_smoke.py +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mvw-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Search and download German public-broadcasting media from MediathekViewWeb, with Plex-friendly season downloads.
|
|
5
|
+
Project-URL: Homepage, https://github.com/maxboettinger/mvw-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/maxboettinger/mvw-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/maxboettinger/mvw-cli/issues
|
|
5
8
|
Author-email: Max Boettinger <perplexity@bttngr.de>
|
|
6
9
|
License-Expression: MIT
|
|
7
10
|
License-File: LICENSE
|
|
@@ -245,6 +248,19 @@ CLI flags always override config file values, which override built-in defaults.
|
|
|
245
248
|
| 4 | HLS entry encountered but ffmpeg is not installed |
|
|
246
249
|
| 5 | Partial/interrupted download failure |
|
|
247
250
|
|
|
251
|
+
## Releasing
|
|
252
|
+
|
|
253
|
+
Cutting a release is one command. It verifies the tree is clean and in sync,
|
|
254
|
+
runs the tests, bumps the version, commits, tags, and pushes — and the pushed
|
|
255
|
+
tag triggers the GitHub Actions workflow that publishes to PyPI via Trusted
|
|
256
|
+
Publishing.
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
scripts/release.sh # patch bump (default): 0.1.0 → 0.1.1
|
|
260
|
+
scripts/release.sh minor # 0.1.0 → 0.2.0
|
|
261
|
+
scripts/release.sh major # 0.1.0 → 1.0.0
|
|
262
|
+
```
|
|
263
|
+
|
|
248
264
|
## Running tests
|
|
249
265
|
|
|
250
266
|
```bash
|
|
@@ -221,6 +221,19 @@ CLI flags always override config file values, which override built-in defaults.
|
|
|
221
221
|
| 4 | HLS entry encountered but ffmpeg is not installed |
|
|
222
222
|
| 5 | Partial/interrupted download failure |
|
|
223
223
|
|
|
224
|
+
## Releasing
|
|
225
|
+
|
|
226
|
+
Cutting a release is one command. It verifies the tree is clean and in sync,
|
|
227
|
+
runs the tests, bumps the version, commits, tags, and pushes — and the pushed
|
|
228
|
+
tag triggers the GitHub Actions workflow that publishes to PyPI via Trusted
|
|
229
|
+
Publishing.
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
scripts/release.sh # patch bump (default): 0.1.0 → 0.1.1
|
|
233
|
+
scripts/release.sh minor # 0.1.0 → 0.2.0
|
|
234
|
+
scripts/release.sh major # 0.1.0 → 1.0.0
|
|
235
|
+
```
|
|
236
|
+
|
|
224
237
|
## Running tests
|
|
225
238
|
|
|
226
239
|
```bash
|
|
@@ -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
|
-
|
|
6
|
+
version = "0.1.2"
|
|
7
7
|
license = "MIT"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
9
9
|
authors = [{ name = "Max Boettinger", email = "perplexity@bttngr.de" }]
|
|
@@ -38,12 +38,13 @@ dependencies = [
|
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
40
40
|
mvw = "mvw.cli:app"
|
|
41
|
+
# Alias matching the distribution name so `uvx mvw-cli ...` works without --from.
|
|
42
|
+
mvw-cli = "mvw.cli:app"
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# Issues = "https://github.com/<you>/mvw-cli/issues"
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/maxboettinger/mvw-cli"
|
|
46
|
+
Repository = "https://github.com/maxboettinger/mvw-cli"
|
|
47
|
+
Issues = "https://github.com/maxboettinger/mvw-cli/issues"
|
|
47
48
|
|
|
48
49
|
[dependency-groups]
|
|
49
50
|
dev = [
|
|
@@ -55,9 +56,6 @@ dev = [
|
|
|
55
56
|
requires = ["hatchling"]
|
|
56
57
|
build-backend = "hatchling.build"
|
|
57
58
|
|
|
58
|
-
[tool.hatch.version]
|
|
59
|
-
path = "src/mvw/__init__.py"
|
|
60
|
-
|
|
61
59
|
[tool.hatch.build.targets.wheel]
|
|
62
60
|
packages = ["src/mvw"]
|
|
63
61
|
|
|
@@ -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
|
|
155
|
-
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
|
|
166
|
-
overall =
|
|
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 =
|
|
188
|
+
task = file_progress.add_task(dest.name, total=None)
|
|
169
189
|
|
|
170
190
|
def cb(done: int, total, _t=task):
|
|
171
|
-
|
|
191
|
+
file_progress.update(_t, completed=done, total=total)
|
|
172
192
|
|
|
173
193
|
try:
|
|
174
194
|
if is_hls(url):
|
|
175
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
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
|