mvw-cli 0.1.0__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.
- mvw/__init__.py +1 -0
- mvw/api.py +69 -0
- mvw/cli.py +236 -0
- mvw/config.py +67 -0
- mvw/display.py +68 -0
- mvw/download.py +92 -0
- mvw/episodes.py +94 -0
- mvw/filters.py +66 -0
- mvw/models.py +109 -0
- mvw/naming.py +47 -0
- mvw/query.py +105 -0
- mvw_cli-0.1.0.dist-info/METADATA +256 -0
- mvw_cli-0.1.0.dist-info/RECORD +16 -0
- mvw_cli-0.1.0.dist-info/WHEEL +4 -0
- mvw_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mvw_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
mvw/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mvw/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from mvw.models import MediathekResult, QueryInfo, QueryResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MediathekError(Exception):
|
|
12
|
+
"""Raised on API errors, HTTP failures, or transport errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MediathekClient:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
user_agent: str = "mvw/0.1.0",
|
|
19
|
+
timeout: float = 30.0,
|
|
20
|
+
retries: int = 2,
|
|
21
|
+
base_url: str = "https://mediathekviewweb.de/api/query",
|
|
22
|
+
) -> None:
|
|
23
|
+
self.user_agent = user_agent
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self.retries = retries
|
|
26
|
+
self.base_url = base_url
|
|
27
|
+
|
|
28
|
+
def query(self, payload: dict) -> QueryResult:
|
|
29
|
+
headers = {"Content-Type": "text/plain", "User-Agent": self.user_agent}
|
|
30
|
+
body = json.dumps(payload)
|
|
31
|
+
transport_err: Exception | None = None
|
|
32
|
+
for _ in range(self.retries + 1):
|
|
33
|
+
try:
|
|
34
|
+
resp = httpx.post(
|
|
35
|
+
self.base_url, content=body, headers=headers, timeout=self.timeout
|
|
36
|
+
)
|
|
37
|
+
except httpx.TransportError as exc:
|
|
38
|
+
transport_err = exc
|
|
39
|
+
continue
|
|
40
|
+
if resp.status_code != 200:
|
|
41
|
+
raise MediathekError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
|
42
|
+
data = resp.json()
|
|
43
|
+
err = data.get("err")
|
|
44
|
+
if err:
|
|
45
|
+
raise MediathekError("; ".join(str(e) for e in err))
|
|
46
|
+
result = data.get("result") or {}
|
|
47
|
+
results = [MediathekResult.from_api(r) for r in result.get("results", [])]
|
|
48
|
+
info = QueryInfo.from_api(result.get("queryInfo", {}))
|
|
49
|
+
return QueryResult(results=results, query_info=info)
|
|
50
|
+
raise MediathekError(f"network error: {transport_err}")
|
|
51
|
+
|
|
52
|
+
def iter_all(
|
|
53
|
+
self, payload: dict, page_size: int = 50, cap: int | None = None
|
|
54
|
+
) -> Iterator[MediathekResult]:
|
|
55
|
+
offset = int(payload.get("offset", 0))
|
|
56
|
+
yielded = 0
|
|
57
|
+
while True:
|
|
58
|
+
page = dict(payload, offset=offset, size=page_size)
|
|
59
|
+
result = self.query(page)
|
|
60
|
+
if not result.results:
|
|
61
|
+
return
|
|
62
|
+
for row in result.results:
|
|
63
|
+
yield row
|
|
64
|
+
yielded += 1
|
|
65
|
+
if cap is not None and yielded >= cap:
|
|
66
|
+
return
|
|
67
|
+
offset += len(result.results)
|
|
68
|
+
if offset >= result.query_info.total_results:
|
|
69
|
+
return
|
mvw/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as jsonlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import (
|
|
10
|
+
BarColumn, DownloadColumn, Progress, SpinnerColumn,
|
|
11
|
+
TextColumn, TransferSpeedColumn,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from mvw import config as configmod
|
|
15
|
+
from mvw import display, episodes, filters, naming, query
|
|
16
|
+
from mvw.api import MediathekClient, MediathekError
|
|
17
|
+
from mvw.download import (
|
|
18
|
+
DownloadError, FFmpegMissingError, download as download_file, download_hls, is_hls, pick_resolution,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Search and download from MediathekViewWeb.", no_args_is_help=True)
|
|
22
|
+
console = Console()
|
|
23
|
+
err_console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_client(cfg: dict) -> MediathekClient:
|
|
27
|
+
return MediathekClient(
|
|
28
|
+
user_agent=cfg["user_agent"], timeout=cfg["request_timeout"]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def search(
|
|
34
|
+
query_str: str = typer.Argument("", help="MVW query string, e.g. '!ARD #Tatort >80'"),
|
|
35
|
+
channel: Optional[str] = typer.Option(None, "--channel"),
|
|
36
|
+
topic: Optional[str] = typer.Option(None, "--topic"),
|
|
37
|
+
title: Optional[str] = typer.Option(None, "--title"),
|
|
38
|
+
description: Optional[str] = typer.Option(None, "--description"),
|
|
39
|
+
min_duration: Optional[int] = typer.Option(None, "--min-duration", help="minutes"),
|
|
40
|
+
max_duration: Optional[int] = typer.Option(None, "--max-duration", help="minutes"),
|
|
41
|
+
sort: str = typer.Option("timestamp", "--sort"),
|
|
42
|
+
order: str = typer.Option("desc", "--order"),
|
|
43
|
+
future: bool = typer.Option(False, "--future"),
|
|
44
|
+
limit: int = typer.Option(15, "--limit"),
|
|
45
|
+
offset: int = typer.Option(0, "--offset"),
|
|
46
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
47
|
+
) -> None:
|
|
48
|
+
cfg = configmod.load()
|
|
49
|
+
payload = query.build_payload(
|
|
50
|
+
query_str or None, channel=channel, topic=topic, title=title,
|
|
51
|
+
description=description, min_duration=min_duration, max_duration=max_duration,
|
|
52
|
+
sort_by=sort, sort_order=order, future=future, offset=offset, size=limit,
|
|
53
|
+
)
|
|
54
|
+
client = _make_client(cfg)
|
|
55
|
+
try:
|
|
56
|
+
result = client.query(payload)
|
|
57
|
+
except MediathekError as exc:
|
|
58
|
+
err_console.print(display.error_panel(str(exc)))
|
|
59
|
+
raise typer.Exit(2)
|
|
60
|
+
|
|
61
|
+
if json_out:
|
|
62
|
+
console.print_json(jsonlib.dumps([r.__dict__ for r in result.results]))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if not result.results:
|
|
66
|
+
console.print("No results.")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
console.print(display.results_table(result.results, start_index=offset + 1))
|
|
70
|
+
total = result.query_info.total_results
|
|
71
|
+
a = offset + 1
|
|
72
|
+
b = offset + len(result.results)
|
|
73
|
+
console.print(
|
|
74
|
+
f"[dim]showing {a}–{b} of {total} · "
|
|
75
|
+
f"{result.query_info.search_engine_time:.1f} ms[/]"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def download(
|
|
81
|
+
query_str: str = typer.Argument("", help="MVW query string"),
|
|
82
|
+
channel: Optional[str] = typer.Option(None, "--channel"),
|
|
83
|
+
topic: Optional[str] = typer.Option(None, "--topic"),
|
|
84
|
+
title: Optional[str] = typer.Option(None, "--title"),
|
|
85
|
+
min_duration: Optional[int] = typer.Option(None, "--min-duration", help="minutes"),
|
|
86
|
+
max_duration: Optional[int] = typer.Option(None, "--max-duration", help="minutes"),
|
|
87
|
+
season: bool = typer.Option(False, "--season", help="group into Plex season folders"),
|
|
88
|
+
dry_run: bool = typer.Option(False, "--dry-run"),
|
|
89
|
+
resolution: Optional[str] = typer.Option(None, "--resolution"),
|
|
90
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o"),
|
|
91
|
+
template: Optional[str] = typer.Option(None, "--template"),
|
|
92
|
+
exclude: list[str] = typer.Option([], "--exclude", help="regex (repeatable)"),
|
|
93
|
+
dedup: bool = typer.Option(False, "--dedup"),
|
|
94
|
+
latest_season: bool = typer.Option(False, "--latest-season"),
|
|
95
|
+
season_number: Optional[int] = typer.Option(None, "--season-number"),
|
|
96
|
+
subtitles: bool = typer.Option(False, "--subtitles"),
|
|
97
|
+
limit: int = typer.Option(200, "--limit", help="max entries to resolve"),
|
|
98
|
+
) -> None:
|
|
99
|
+
cfg = configmod.load()
|
|
100
|
+
pref = resolution or cfg["resolution"]
|
|
101
|
+
tmpl = template or cfg["template"]
|
|
102
|
+
out_dir = output or Path(cfg["download_dir"])
|
|
103
|
+
|
|
104
|
+
payload = query.build_payload(
|
|
105
|
+
query_str or None, channel=channel, topic=topic, title=title,
|
|
106
|
+
min_duration=min_duration, max_duration=max_duration,
|
|
107
|
+
)
|
|
108
|
+
client = _make_client(cfg)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with console.status("Searching…", spinner="dots"):
|
|
112
|
+
rows = list(client.iter_all(payload, page_size=cfg["page_size"], cap=limit))
|
|
113
|
+
except MediathekError as exc:
|
|
114
|
+
err_console.print(display.error_panel(str(exc)))
|
|
115
|
+
raise typer.Exit(2)
|
|
116
|
+
|
|
117
|
+
rows = filters.exclude(rows, exclude)
|
|
118
|
+
if dedup:
|
|
119
|
+
rows = filters.dedup(rows)
|
|
120
|
+
if latest_season:
|
|
121
|
+
rows = filters.latest_season(rows)
|
|
122
|
+
|
|
123
|
+
if not rows:
|
|
124
|
+
console.print("No matching entries to download.")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
eps = episodes.assign(rows, season_override=season_number)
|
|
128
|
+
if season:
|
|
129
|
+
ordered = [e for _s, lst in episodes.group_by_season(eps).items() for e in lst]
|
|
130
|
+
else:
|
|
131
|
+
ordered = eps
|
|
132
|
+
|
|
133
|
+
# Build plan: (dest_path, url, tier, subtitle_url)
|
|
134
|
+
plan: list[tuple[Path, str, str, str]] = []
|
|
135
|
+
for ep in ordered:
|
|
136
|
+
try:
|
|
137
|
+
url, tier = pick_resolution(ep.result, pref)
|
|
138
|
+
except DownloadError as exc:
|
|
139
|
+
err_console.print(display.error_panel(f"{ep.result.title}: {exc}"))
|
|
140
|
+
continue
|
|
141
|
+
ext = "mp4"
|
|
142
|
+
rendered = naming.render(ep, template=tmpl, tier=tier, ext=ext)
|
|
143
|
+
dest = out_dir / rendered if season else out_dir / rendered.name
|
|
144
|
+
plan.append((dest, url, tier, ep.result.url_subtitle))
|
|
145
|
+
|
|
146
|
+
if dry_run:
|
|
147
|
+
console.print(display.dry_run_tree([(d, u, t) for d, u, t, _ in plan]))
|
|
148
|
+
console.print(f"[dim]{len(plan)} file(s) planned[/]")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
_run_downloads(plan, subtitles=subtitles)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _run_downloads(plan, *, subtitles: bool) -> None:
|
|
155
|
+
progress = Progress(
|
|
156
|
+
SpinnerColumn(),
|
|
157
|
+
TextColumn("[progress.description]{task.description}"),
|
|
158
|
+
BarColumn(),
|
|
159
|
+
DownloadColumn(),
|
|
160
|
+
TransferSpeedColumn(),
|
|
161
|
+
console=console,
|
|
162
|
+
)
|
|
163
|
+
failures = 0
|
|
164
|
+
ffmpeg_missing = False
|
|
165
|
+
with progress:
|
|
166
|
+
overall = progress.add_task("Overall", total=len(plan))
|
|
167
|
+
for dest, url, _tier, sub_url in plan:
|
|
168
|
+
task = progress.add_task(dest.name, total=None)
|
|
169
|
+
|
|
170
|
+
def cb(done: int, total, _t=task):
|
|
171
|
+
progress.update(_t, completed=done, total=total)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
if is_hls(url):
|
|
175
|
+
progress.update(task, description=f"{dest.name} (ffmpeg)")
|
|
176
|
+
download_hls(url, dest)
|
|
177
|
+
else:
|
|
178
|
+
download_file(url, dest, on_progress=cb)
|
|
179
|
+
if subtitles and sub_url:
|
|
180
|
+
download_file(sub_url, dest.with_suffix(".xml"))
|
|
181
|
+
except FFmpegMissingError as exc:
|
|
182
|
+
ffmpeg_missing = True
|
|
183
|
+
err_console.print(display.error_panel(
|
|
184
|
+
f"{dest.name}: {exc}\nInstall ffmpeg: https://ffmpeg.org/download.html"
|
|
185
|
+
))
|
|
186
|
+
except DownloadError as exc:
|
|
187
|
+
failures += 1
|
|
188
|
+
err_console.print(display.error_panel(f"{dest.name}: {exc}"))
|
|
189
|
+
finally:
|
|
190
|
+
progress.update(task, visible=False)
|
|
191
|
+
progress.advance(overall)
|
|
192
|
+
if ffmpeg_missing:
|
|
193
|
+
raise typer.Exit(4)
|
|
194
|
+
elif failures:
|
|
195
|
+
raise typer.Exit(5)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def info(target: str = typer.Argument(..., help="query string; shows the first match")) -> None:
|
|
200
|
+
cfg = configmod.load()
|
|
201
|
+
payload = query.build_payload(target, size=1)
|
|
202
|
+
client = _make_client(cfg)
|
|
203
|
+
try:
|
|
204
|
+
result = client.query(payload)
|
|
205
|
+
except MediathekError as exc:
|
|
206
|
+
err_console.print(display.error_panel(str(exc)))
|
|
207
|
+
raise typer.Exit(2)
|
|
208
|
+
if not result.results:
|
|
209
|
+
console.print("No results.")
|
|
210
|
+
raise typer.Exit(0)
|
|
211
|
+
console.print(display.detail_panel(result.results[0]))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
config_app = typer.Typer(help="Manage configuration.")
|
|
215
|
+
app.add_typer(config_app, name="config")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@config_app.command("show")
|
|
219
|
+
def config_show() -> None:
|
|
220
|
+
for k, v in configmod.load().items():
|
|
221
|
+
console.print(f"[cyan]{k}[/] = {v}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@config_app.command("set")
|
|
225
|
+
def config_set(key: str, value: str) -> None:
|
|
226
|
+
configmod.set_value(key, value)
|
|
227
|
+
console.print(f"Set [cyan]{key}[/] = {value}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@config_app.command("path")
|
|
231
|
+
def config_path_cmd() -> None:
|
|
232
|
+
console.print(str(configmod.config_path()))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
app()
|
mvw/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import platformdirs
|
|
7
|
+
|
|
8
|
+
from mvw.naming import DEFAULT_TEMPLATE
|
|
9
|
+
|
|
10
|
+
DEFAULTS: dict = {
|
|
11
|
+
"download_dir": ".",
|
|
12
|
+
"template": DEFAULT_TEMPLATE,
|
|
13
|
+
"resolution": "best",
|
|
14
|
+
"user_agent": "mvw/0.1.0",
|
|
15
|
+
"page_size": 50,
|
|
16
|
+
"request_timeout": 30.0,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_INT_KEYS = {"page_size"}
|
|
20
|
+
_FLOAT_KEYS = {"request_timeout"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_path() -> Path:
|
|
24
|
+
return Path(platformdirs.user_config_dir("mvw")) / "config.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load(path: Path | None = None) -> dict:
|
|
28
|
+
path = path or config_path()
|
|
29
|
+
cfg = dict(DEFAULTS)
|
|
30
|
+
if path.exists():
|
|
31
|
+
with open(path, "rb") as fh:
|
|
32
|
+
cfg.update(tomllib.load(fh))
|
|
33
|
+
return cfg
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _coerce(key: str, value: str):
|
|
37
|
+
if key in _INT_KEYS:
|
|
38
|
+
return int(value)
|
|
39
|
+
if key in _FLOAT_KEYS:
|
|
40
|
+
return float(value)
|
|
41
|
+
if value.lower() in ("true", "false"):
|
|
42
|
+
return value.lower() == "true"
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dump_toml(data: dict) -> str:
|
|
47
|
+
lines = []
|
|
48
|
+
for k, v in data.items():
|
|
49
|
+
if isinstance(v, bool):
|
|
50
|
+
lines.append(f"{k} = {str(v).lower()}")
|
|
51
|
+
elif isinstance(v, (int, float)):
|
|
52
|
+
lines.append(f"{k} = {v}")
|
|
53
|
+
else:
|
|
54
|
+
escaped = str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
55
|
+
lines.append(f'{k} = "{escaped}"')
|
|
56
|
+
return "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_value(key: str, value: str, path: Path | None = None) -> None:
|
|
60
|
+
path = path or config_path()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
current: dict = {}
|
|
63
|
+
if path.exists():
|
|
64
|
+
with open(path, "rb") as fh:
|
|
65
|
+
current = tomllib.load(fh)
|
|
66
|
+
current[key] = _coerce(key, value)
|
|
67
|
+
path.write_text(_dump_toml(current))
|
mvw/display.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.tree import Tree
|
|
8
|
+
|
|
9
|
+
from mvw.models import MediathekResult
|
|
10
|
+
|
|
11
|
+
_TIER_BADGE = {"low": "LOW", "medium": "SD", "high": "HD"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def res_badges(result: MediathekResult) -> str:
|
|
15
|
+
return " ".join(_TIER_BADGE[t] for t in result.resolutions)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def results_table(results: list[MediathekResult], *, start_index: int = 1) -> Table:
|
|
19
|
+
table = Table(show_lines=False, expand=True)
|
|
20
|
+
table.add_column("#", justify="right", style="dim", no_wrap=True)
|
|
21
|
+
table.add_column("Channel", style="cyan", no_wrap=True)
|
|
22
|
+
table.add_column("Topic", style="magenta")
|
|
23
|
+
table.add_column("Title")
|
|
24
|
+
table.add_column("Date", no_wrap=True)
|
|
25
|
+
table.add_column("Dur", justify="right", no_wrap=True)
|
|
26
|
+
table.add_column("Res", no_wrap=True)
|
|
27
|
+
for i, r in enumerate(results, start=start_index):
|
|
28
|
+
table.add_row(
|
|
29
|
+
str(i), r.channel, r.topic, r.title,
|
|
30
|
+
r.aired.strftime("%Y-%m-%d"), r.duration_human, res_badges(r),
|
|
31
|
+
)
|
|
32
|
+
return table
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detail_panel(result: MediathekResult) -> Panel:
|
|
36
|
+
lines = [
|
|
37
|
+
f"[bold]Channel:[/] {result.channel}",
|
|
38
|
+
f"[bold]Topic:[/] {result.topic}",
|
|
39
|
+
f"[bold]Title:[/] {result.title}",
|
|
40
|
+
f"[bold]Aired:[/] {result.aired.strftime('%Y-%m-%d %H:%M')}",
|
|
41
|
+
f"[bold]Duration:[/] {result.duration_human}",
|
|
42
|
+
f"[bold]Size:[/] {result.size_human}",
|
|
43
|
+
f"[bold]Resolutions:[/] {res_badges(result) or '—'}",
|
|
44
|
+
"",
|
|
45
|
+
result.description or "(no description)",
|
|
46
|
+
"",
|
|
47
|
+
f"[dim]Video:[/] {result.url_video or '—'}",
|
|
48
|
+
f"[dim]HD:[/] {result.url_video_hd or '—'}",
|
|
49
|
+
f"[dim]Low:[/] {result.url_video_low or '—'}",
|
|
50
|
+
f"[dim]Subtitle:[/] {result.url_subtitle or '—'}",
|
|
51
|
+
f"[dim]Website:[/] {result.url_website or '—'}",
|
|
52
|
+
]
|
|
53
|
+
return Panel("\n".join(lines), title=result.title, border_style="cyan")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dry_run_tree(plans: list[tuple[Path, str, str]]) -> Tree:
|
|
57
|
+
root = Tree("[bold]Planned downloads[/]")
|
|
58
|
+
folders: dict[str, Tree] = {}
|
|
59
|
+
for dest, _url, tier in plans:
|
|
60
|
+
parent_key = str(dest.parent)
|
|
61
|
+
if parent_key not in folders:
|
|
62
|
+
folders[parent_key] = root.add(f"[blue]{parent_key}[/]")
|
|
63
|
+
folders[parent_key].add(f"{dest.name} [dim]({tier})[/]")
|
|
64
|
+
return root
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def error_panel(message: str) -> Panel:
|
|
68
|
+
return Panel(message, title="Error", border_style="red")
|
mvw/download.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mvw.models import MediathekResult
|
|
11
|
+
|
|
12
|
+
ProgressCb = Callable[[int, "int | None"], None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DownloadError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FFmpegMissingError(DownloadError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_hls(url: str) -> bool:
|
|
24
|
+
return ".m3u8" in url.split("?", 1)[0].lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pick_resolution(result: MediathekResult, preference: str) -> tuple[str, str]:
|
|
28
|
+
url, tier = result.resolve_video(preference)
|
|
29
|
+
if not url:
|
|
30
|
+
raise DownloadError("no video URL available for this entry")
|
|
31
|
+
return url, tier
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def download(
|
|
35
|
+
url: str,
|
|
36
|
+
dest: Path,
|
|
37
|
+
*,
|
|
38
|
+
on_progress: ProgressCb | None = None,
|
|
39
|
+
resume: bool = True,
|
|
40
|
+
client: httpx.Client | None = None,
|
|
41
|
+
) -> Path:
|
|
42
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
part = dest.with_name(dest.name + ".part")
|
|
44
|
+
existing = part.stat().st_size if (resume and part.exists()) else 0
|
|
45
|
+
|
|
46
|
+
headers = {}
|
|
47
|
+
mode = "wb"
|
|
48
|
+
if existing:
|
|
49
|
+
headers["Range"] = f"bytes={existing}-"
|
|
50
|
+
mode = "ab"
|
|
51
|
+
|
|
52
|
+
owns_client = client is None
|
|
53
|
+
client = client or httpx.Client(timeout=60.0, follow_redirects=True)
|
|
54
|
+
try:
|
|
55
|
+
with client.stream("GET", url, headers=headers) as resp:
|
|
56
|
+
if resp.status_code not in (200, 206):
|
|
57
|
+
raise DownloadError(f"HTTP {resp.status_code} downloading {url}")
|
|
58
|
+
if resp.status_code == 200:
|
|
59
|
+
existing = 0
|
|
60
|
+
mode = "wb"
|
|
61
|
+
total: int | None = None
|
|
62
|
+
cl = resp.headers.get("content-length")
|
|
63
|
+
if cl is not None:
|
|
64
|
+
total = int(cl) + existing
|
|
65
|
+
downloaded = existing
|
|
66
|
+
with open(part, mode) as fh:
|
|
67
|
+
for chunk in resp.iter_bytes():
|
|
68
|
+
fh.write(chunk)
|
|
69
|
+
downloaded += len(chunk)
|
|
70
|
+
if on_progress:
|
|
71
|
+
on_progress(downloaded, total)
|
|
72
|
+
except httpx.TransportError as exc:
|
|
73
|
+
raise DownloadError(f"network error: {exc}") from exc
|
|
74
|
+
finally:
|
|
75
|
+
if owns_client:
|
|
76
|
+
client.close()
|
|
77
|
+
|
|
78
|
+
part.replace(dest)
|
|
79
|
+
return dest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def download_hls(url: str, dest: Path, *, ffmpeg: str = "ffmpeg") -> Path:
|
|
83
|
+
if shutil.which(ffmpeg) is None:
|
|
84
|
+
raise FFmpegMissingError(
|
|
85
|
+
"ffmpeg not found on PATH; required for HLS (.m3u8) downloads"
|
|
86
|
+
)
|
|
87
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
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:]}")
|
|
92
|
+
return dest
|
mvw/episodes.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from mvw.models import MediathekResult
|
|
7
|
+
|
|
8
|
+
_SE = re.compile(r"S\s*(\d{1,2})\s*[ _/.\-]?\s*E\s*(\d{1,3})", re.IGNORECASE)
|
|
9
|
+
_STAFFEL_FOLGE = re.compile(r"Staffel\s*(\d{1,2}).*?Folge\s*(\d{1,3})", re.IGNORECASE)
|
|
10
|
+
_FOLGE = re.compile(r"\bFolge\s*(\d{1,3})\b", re.IGNORECASE)
|
|
11
|
+
_FOLGE_PRE = re.compile(r"\b(\d{1,3})\.\s*Folge\b", re.IGNORECASE)
|
|
12
|
+
_TRAILING_NUM = re.compile(r"\((\d{1,3})\)\s*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_se(title: str) -> tuple[int | None, int | None]:
|
|
16
|
+
m = _SE.search(title)
|
|
17
|
+
if m:
|
|
18
|
+
return int(m.group(1)), int(m.group(2))
|
|
19
|
+
m = _STAFFEL_FOLGE.search(title)
|
|
20
|
+
if m:
|
|
21
|
+
return int(m.group(1)), int(m.group(2))
|
|
22
|
+
m = _FOLGE.search(title) or _FOLGE_PRE.search(title)
|
|
23
|
+
if m:
|
|
24
|
+
return None, int(m.group(1))
|
|
25
|
+
m = _TRAILING_NUM.search(title)
|
|
26
|
+
if m:
|
|
27
|
+
return None, int(m.group(1))
|
|
28
|
+
return None, None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clean_episode_title(title: str) -> str:
|
|
32
|
+
t = _SE.sub("", title)
|
|
33
|
+
t = _STAFFEL_FOLGE.sub("", t)
|
|
34
|
+
t = _FOLGE.sub("", t)
|
|
35
|
+
t = _TRAILING_NUM.sub("", t)
|
|
36
|
+
t = re.sub(r"[\s\-–—:|()]+$", "", t)
|
|
37
|
+
t = re.sub(r"^[\s\-–—:|()]+", "", t)
|
|
38
|
+
return t.strip() or title.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Episode:
|
|
43
|
+
result: MediathekResult
|
|
44
|
+
season: int
|
|
45
|
+
episode: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assign(
|
|
49
|
+
results: list[MediathekResult], *, season_override: int | None = None
|
|
50
|
+
) -> list[Episode]:
|
|
51
|
+
parsed = [(r, *parse_se(r.title)) for r in results]
|
|
52
|
+
|
|
53
|
+
# Resolve each row's season first.
|
|
54
|
+
def season_of(r: MediathekResult, s: int | None) -> int:
|
|
55
|
+
if season_override is not None:
|
|
56
|
+
return season_override
|
|
57
|
+
if s is not None:
|
|
58
|
+
return s
|
|
59
|
+
return r.aired.year
|
|
60
|
+
|
|
61
|
+
enriched = [(r, season_of(r, s), e) for (r, s, e) in parsed]
|
|
62
|
+
|
|
63
|
+
# Per-season episode numbering by timestamp order; explicit numbers win.
|
|
64
|
+
episodes: list[Episode] = []
|
|
65
|
+
by_season: dict[int, list[tuple[MediathekResult, int | None]]] = {}
|
|
66
|
+
for r, season, e in enriched:
|
|
67
|
+
by_season.setdefault(season, []).append((r, e))
|
|
68
|
+
|
|
69
|
+
for season, rows in by_season.items():
|
|
70
|
+
rows.sort(key=lambda re_: re_[0].timestamp)
|
|
71
|
+
used = {e for _, e in rows if e is not None}
|
|
72
|
+
counter = 0
|
|
73
|
+
for r, e in rows:
|
|
74
|
+
if e is None:
|
|
75
|
+
counter += 1
|
|
76
|
+
while counter in used:
|
|
77
|
+
counter += 1
|
|
78
|
+
used.add(counter)
|
|
79
|
+
num = counter
|
|
80
|
+
else:
|
|
81
|
+
num = e
|
|
82
|
+
counter = max(counter, e)
|
|
83
|
+
episodes.append(Episode(result=r, season=season, episode=num))
|
|
84
|
+
return episodes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def group_by_season(episodes: list[Episode]) -> dict[int, list[Episode]]:
|
|
88
|
+
grouped: dict[int, list[Episode]] = {}
|
|
89
|
+
for ep in episodes:
|
|
90
|
+
grouped.setdefault(ep.season, []).append(ep)
|
|
91
|
+
return {
|
|
92
|
+
season: sorted(grouped[season], key=lambda e: e.episode)
|
|
93
|
+
for season in sorted(grouped)
|
|
94
|
+
}
|
mvw/filters.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from mvw.models import MediathekResult
|
|
6
|
+
|
|
7
|
+
_VARIANT_MARKERS = [
|
|
8
|
+
r"\(?audiodeskription\)?",
|
|
9
|
+
r"\(?mit geb(ä|ae)rdensprache\)?",
|
|
10
|
+
r"\(?geb(ä|ae)rdensprache\)?",
|
|
11
|
+
r"\(?h(ö|oe)rfassung\)?",
|
|
12
|
+
]
|
|
13
|
+
_PUNCT = re.compile(r"[^\w\s]", re.UNICODE)
|
|
14
|
+
_WS = re.compile(r"\s+")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_title(title: str) -> str:
|
|
18
|
+
t = title.lower()
|
|
19
|
+
for marker in _VARIANT_MARKERS:
|
|
20
|
+
t = re.sub(marker, " ", t)
|
|
21
|
+
t = _PUNCT.sub(" ", t)
|
|
22
|
+
return _WS.sub(" ", t).strip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def exclude(results: list[MediathekResult], patterns: list[str]) -> list[MediathekResult]:
|
|
26
|
+
if not patterns:
|
|
27
|
+
return list(results)
|
|
28
|
+
compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
29
|
+
kept = []
|
|
30
|
+
for r in results:
|
|
31
|
+
haystack = f"{r.title}\n{r.topic}\n{r.description}"
|
|
32
|
+
if any(c.search(haystack) for c in compiled):
|
|
33
|
+
continue
|
|
34
|
+
kept.append(r)
|
|
35
|
+
return kept
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _score(r: MediathekResult) -> tuple[int, int]:
|
|
39
|
+
return (len(r.resolutions), r.duration)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dedup(results: list[MediathekResult]) -> list[MediathekResult]:
|
|
43
|
+
best: dict[str, MediathekResult] = {}
|
|
44
|
+
order: list[str] = []
|
|
45
|
+
for r in results:
|
|
46
|
+
key = f"{r.topic.lower().strip()}|{normalize_title(r.title)}"
|
|
47
|
+
if key not in best:
|
|
48
|
+
best[key] = r
|
|
49
|
+
order.append(key)
|
|
50
|
+
elif _score(r) > _score(best[key]):
|
|
51
|
+
best[key] = r
|
|
52
|
+
return [best[k] for k in order]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def latest_season(results: list[MediathekResult]) -> list[MediathekResult]:
|
|
56
|
+
from mvw.episodes import parse_se
|
|
57
|
+
|
|
58
|
+
seasons = []
|
|
59
|
+
for r in results:
|
|
60
|
+
season, _ = parse_se(r.title)
|
|
61
|
+
seasons.append(season)
|
|
62
|
+
detected = [s for s in seasons if s is not None]
|
|
63
|
+
if not detected:
|
|
64
|
+
return list(results)
|
|
65
|
+
top = max(detected)
|
|
66
|
+
return [r for r, s in zip(results, seasons) if s == top]
|
mvw/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
_TIER_TO_FIELD = {"high": "url_video_hd", "medium": "url_video", "low": "url_video_low"}
|
|
7
|
+
_FALLBACK_ORDER = {
|
|
8
|
+
"best": ["high", "medium", "low"],
|
|
9
|
+
"high": ["high", "medium", "low"],
|
|
10
|
+
"medium": ["medium", "low", "high"],
|
|
11
|
+
"low": ["low", "medium", "high"],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MediathekResult:
|
|
17
|
+
channel: str
|
|
18
|
+
topic: str
|
|
19
|
+
title: str
|
|
20
|
+
description: str
|
|
21
|
+
timestamp: int
|
|
22
|
+
duration: int
|
|
23
|
+
size: int
|
|
24
|
+
url_website: str
|
|
25
|
+
url_subtitle: str
|
|
26
|
+
url_video: str
|
|
27
|
+
url_video_low: str
|
|
28
|
+
url_video_hd: str
|
|
29
|
+
filmliste_timestamp: str
|
|
30
|
+
id: str
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_api(cls, d: dict) -> "MediathekResult":
|
|
34
|
+
g = d.get
|
|
35
|
+
return cls(
|
|
36
|
+
channel=g("channel", "") or "",
|
|
37
|
+
topic=g("topic", "") or "",
|
|
38
|
+
title=g("title", "") or "",
|
|
39
|
+
description=g("description", "") or "",
|
|
40
|
+
timestamp=int(g("timestamp", 0) or 0),
|
|
41
|
+
duration=int(g("duration", 0) or 0),
|
|
42
|
+
size=int(g("size", 0) or 0),
|
|
43
|
+
url_website=g("url_website", "") or "",
|
|
44
|
+
url_subtitle=g("url_subtitle", "") or "",
|
|
45
|
+
url_video=g("url_video", "") or "",
|
|
46
|
+
url_video_low=g("url_video_low", "") or "",
|
|
47
|
+
url_video_hd=g("url_video_hd", "") or "",
|
|
48
|
+
filmliste_timestamp=str(g("filmlisteTimestamp", "") or ""),
|
|
49
|
+
id=str(g("id", "") or ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def aired(self) -> datetime:
|
|
54
|
+
return datetime.fromtimestamp(self.timestamp, tz=timezone.utc)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def duration_human(self) -> str:
|
|
58
|
+
s = self.duration
|
|
59
|
+
h, rem = divmod(s, 3600)
|
|
60
|
+
m, sec = divmod(rem, 60)
|
|
61
|
+
if h:
|
|
62
|
+
return f"{h}:{m:02d}:{sec:02d}"
|
|
63
|
+
return f"{m}:{sec:02d}"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def size_human(self) -> str:
|
|
67
|
+
n = float(self.size)
|
|
68
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
69
|
+
if n < 1024 or unit == "TB":
|
|
70
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
71
|
+
n /= 1024
|
|
72
|
+
return f"{n:.1f} TB"
|
|
73
|
+
|
|
74
|
+
def resolution_present(self, tier: str) -> bool:
|
|
75
|
+
return bool(getattr(self, _TIER_TO_FIELD[tier], ""))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def resolutions(self) -> list[str]:
|
|
79
|
+
return [t for t in ("low", "medium", "high") if self.resolution_present(t)]
|
|
80
|
+
|
|
81
|
+
def resolve_video(self, preference: str) -> tuple[str | None, str | None]:
|
|
82
|
+
for tier in _FALLBACK_ORDER.get(preference, _FALLBACK_ORDER["best"]):
|
|
83
|
+
url = getattr(self, _TIER_TO_FIELD[tier], "")
|
|
84
|
+
if url:
|
|
85
|
+
return url, tier
|
|
86
|
+
return None, None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class QueryInfo:
|
|
91
|
+
total_results: int
|
|
92
|
+
result_count: int
|
|
93
|
+
search_engine_time: float
|
|
94
|
+
filmliste_timestamp: str
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_api(cls, d: dict) -> "QueryInfo":
|
|
98
|
+
return cls(
|
|
99
|
+
total_results=int(d.get("totalResults", 0) or 0),
|
|
100
|
+
result_count=int(d.get("resultCount", 0) or 0),
|
|
101
|
+
search_engine_time=float(d.get("searchEngineTime", 0) or 0),
|
|
102
|
+
filmliste_timestamp=str(d.get("filmlisteTimestamp", "") or ""),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class QueryResult:
|
|
108
|
+
results: list[MediathekResult]
|
|
109
|
+
query_info: QueryInfo
|
mvw/naming.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mvw.episodes import Episode, clean_episode_title
|
|
7
|
+
|
|
8
|
+
DEFAULT_TEMPLATE = (
|
|
9
|
+
"{series} ({year})/Season {s:02d}/"
|
|
10
|
+
"{series} ({year}) - s{s:02d}e{e:02d} - {ep_title} [{res}].{ext}"
|
|
11
|
+
)
|
|
12
|
+
RES_LABELS = {"high": "1080p", "medium": "720p", "low": "480p"}
|
|
13
|
+
|
|
14
|
+
_ILLEGAL = re.compile(r'[/\\:*?"<>|]')
|
|
15
|
+
_WS = re.compile(r"\s+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sanitize(component: str) -> str:
|
|
19
|
+
s = _ILLEGAL.sub(" ", component)
|
|
20
|
+
s = _WS.sub(" ", s).strip()
|
|
21
|
+
s = s.rstrip(". ")
|
|
22
|
+
return s[:150]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render(
|
|
26
|
+
episode: Episode, *, template: str = DEFAULT_TEMPLATE, tier: str, ext: str
|
|
27
|
+
) -> Path:
|
|
28
|
+
r = episode.result
|
|
29
|
+
values = {
|
|
30
|
+
"series": r.topic,
|
|
31
|
+
"year": r.aired.year,
|
|
32
|
+
"s": episode.season,
|
|
33
|
+
"e": episode.episode,
|
|
34
|
+
"ep_title": clean_episode_title(r.title),
|
|
35
|
+
"res": RES_LABELS.get(tier, tier),
|
|
36
|
+
"channel": r.channel,
|
|
37
|
+
"date": r.aired.strftime("%Y-%m-%d"),
|
|
38
|
+
"ext": ext,
|
|
39
|
+
}
|
|
40
|
+
# Scrub path separators from string values to prevent path injection.
|
|
41
|
+
# Integers (year, s, e) are left unchanged to preserve format specs like :02d.
|
|
42
|
+
for key in ["series", "ep_title", "res", "channel", "date", "ext"]:
|
|
43
|
+
if isinstance(values[key], str):
|
|
44
|
+
values[key] = values[key].replace("/", " ").replace("\\", " ")
|
|
45
|
+
rendered = template.format(**values)
|
|
46
|
+
parts = [sanitize(p) for p in rendered.split("/") if p]
|
|
47
|
+
return Path(*parts)
|
mvw/query.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
_PREFIX_FIELD = {"!": "channel", "#": "topic", "+": "title", "*": "description"}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_raw(raw: str) -> tuple[list[dict], int | None, int | None]:
|
|
7
|
+
"""Translate the MVW query-string grammar into API query dicts.
|
|
8
|
+
|
|
9
|
+
Same selector repeated -> OR (values joined by space, MVW's OR semantics).
|
|
10
|
+
Bare words -> default topic+title field. `>N`/`<N` are duration in minutes.
|
|
11
|
+
Topic prefixes collect immediately following bare words; other prefixes do not.
|
|
12
|
+
"""
|
|
13
|
+
by_field: dict[str, list[str]] = {}
|
|
14
|
+
bare: list[str] = []
|
|
15
|
+
dur_min: int | None = None
|
|
16
|
+
dur_max: int | None = None
|
|
17
|
+
|
|
18
|
+
tokens = raw.split()
|
|
19
|
+
i = 0
|
|
20
|
+
|
|
21
|
+
while i < len(tokens):
|
|
22
|
+
token = tokens[i]
|
|
23
|
+
head = token[0] if token else ""
|
|
24
|
+
|
|
25
|
+
if head == ">" and len(token) > 1 and token[1:].isdigit():
|
|
26
|
+
dur_min = int(token[1:]) * 60
|
|
27
|
+
i += 1
|
|
28
|
+
elif head == "<" and len(token) > 1 and token[1:].isdigit():
|
|
29
|
+
dur_max = int(token[1:]) * 60
|
|
30
|
+
i += 1
|
|
31
|
+
elif head in _PREFIX_FIELD and len(token) > 1:
|
|
32
|
+
field = _PREFIX_FIELD[head]
|
|
33
|
+
values = [token[1:]]
|
|
34
|
+
i += 1
|
|
35
|
+
# Topic prefix collects immediately following bare tokens
|
|
36
|
+
if field == "topic":
|
|
37
|
+
while i < len(tokens):
|
|
38
|
+
next_token = tokens[i]
|
|
39
|
+
next_head = next_token[0] if next_token else ""
|
|
40
|
+
# Stop if next token is a prefix or duration
|
|
41
|
+
if next_head in _PREFIX_FIELD and len(next_token) > 1:
|
|
42
|
+
break
|
|
43
|
+
if next_head in ('>', '<') and len(next_token) > 1 and next_token[1:].isdigit():
|
|
44
|
+
break
|
|
45
|
+
# This bare token follows topic prefix, collect it
|
|
46
|
+
values.append(next_token)
|
|
47
|
+
i += 1
|
|
48
|
+
by_field.setdefault(field, []).extend(values)
|
|
49
|
+
else:
|
|
50
|
+
bare.append(token)
|
|
51
|
+
i += 1
|
|
52
|
+
|
|
53
|
+
queries: list[dict] = []
|
|
54
|
+
for field, values in by_field.items():
|
|
55
|
+
queries.append({"fields": [field], "query": " ".join(values)})
|
|
56
|
+
if bare:
|
|
57
|
+
queries.append({"fields": ["topic", "title"], "query": " ".join(bare)})
|
|
58
|
+
return queries, dur_min, dur_max
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_payload(
|
|
62
|
+
raw: str | None = None,
|
|
63
|
+
*,
|
|
64
|
+
channel: str | None = None,
|
|
65
|
+
topic: str | None = None,
|
|
66
|
+
title: str | None = None,
|
|
67
|
+
description: str | None = None,
|
|
68
|
+
min_duration: int | None = None,
|
|
69
|
+
max_duration: int | None = None,
|
|
70
|
+
sort_by: str = "timestamp",
|
|
71
|
+
sort_order: str = "desc",
|
|
72
|
+
future: bool = False,
|
|
73
|
+
offset: int = 0,
|
|
74
|
+
size: int = 15,
|
|
75
|
+
) -> dict:
|
|
76
|
+
queries: list[dict] = []
|
|
77
|
+
dur_min = dur_max = None
|
|
78
|
+
if raw:
|
|
79
|
+
queries, dur_min, dur_max = parse_raw(raw)
|
|
80
|
+
|
|
81
|
+
for field, value in (
|
|
82
|
+
("channel", channel), ("topic", topic),
|
|
83
|
+
("title", title), ("description", description),
|
|
84
|
+
):
|
|
85
|
+
if value:
|
|
86
|
+
queries.append({"fields": [field], "query": value})
|
|
87
|
+
|
|
88
|
+
if min_duration is not None:
|
|
89
|
+
dur_min = min_duration * 60
|
|
90
|
+
if max_duration is not None:
|
|
91
|
+
dur_max = max_duration * 60
|
|
92
|
+
|
|
93
|
+
payload: dict = {
|
|
94
|
+
"queries": queries,
|
|
95
|
+
"sortBy": sort_by,
|
|
96
|
+
"sortOrder": sort_order,
|
|
97
|
+
"future": future,
|
|
98
|
+
"offset": offset,
|
|
99
|
+
"size": size,
|
|
100
|
+
}
|
|
101
|
+
if dur_min is not None:
|
|
102
|
+
payload["duration_min"] = dur_min
|
|
103
|
+
if dur_max is not None:
|
|
104
|
+
payload["duration_max"] = dur_max
|
|
105
|
+
return payload
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mvw-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Search and download German public-broadcasting media from MediathekViewWeb, with Plex-friendly season downloads.
|
|
5
|
+
Author-email: Max Boettinger <perplexity@bttngr.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ard,cli,download,german,mediathek,mediathekviewweb,plex,television,zdf
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Natural Language :: German
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Multimedia :: Video
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: platformdirs>=4.2
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mvw
|
|
26
|
+
|
|
27
|
+
A command-line tool for searching and downloading content from
|
|
28
|
+
[MediathekViewWeb](https://mediathekviewweb.de/) (MVW), the index of German
|
|
29
|
+
public-broadcasting media libraries (ARD, ZDF, WDR, and more). Built for
|
|
30
|
+
automation: the headline feature is reliable, Plex-friendly **season**
|
|
31
|
+
downloads.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Requires Python ≥ 3.13. The distribution is published as **`mvw-cli`** (the
|
|
36
|
+
PyPI name `mvw` was already taken); the installed command is `mvw`.
|
|
37
|
+
|
|
38
|
+
Install as a standalone tool with [uv](https://github.com/astral-sh/uv):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install mvw-cli # adds the `mvw` command to your PATH
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or run it once without installing:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uvx --from mvw-cli mvw search "#Tatort"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With pip:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install mvw-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> **Note:** HLS (`.m3u8`) downloads require [ffmpeg](https://ffmpeg.org/download.html)
|
|
57
|
+
> on your `PATH`. It is an external (non-Python) dependency and is not installed
|
|
58
|
+
> automatically.
|
|
59
|
+
|
|
60
|
+
### From source
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv sync # create the dev environment
|
|
64
|
+
uv run mvw --help # run from the working tree
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Query grammar
|
|
68
|
+
|
|
69
|
+
The query string follows the MediathekViewWeb syntax:
|
|
70
|
+
|
|
71
|
+
| Prefix | Field searched | Example |
|
|
72
|
+
|--------|---------------|---------|
|
|
73
|
+
| `!` | channel | `!ARD` |
|
|
74
|
+
| `#` | topic | `#Tatort` |
|
|
75
|
+
| `+` | title | `+Schokolade` |
|
|
76
|
+
| `*` | description | `*Berlin` |
|
|
77
|
+
| (none) | topic and title | `feuer flamme` |
|
|
78
|
+
| `>N` | duration > N minutes | `>80` |
|
|
79
|
+
| `<N` | duration < N minutes | `<10` |
|
|
80
|
+
|
|
81
|
+
Combination rules:
|
|
82
|
+
|
|
83
|
+
- **Space between different selectors** → AND: `!WDR #Tatort` means channel=WDR
|
|
84
|
+
AND topic=Tatort.
|
|
85
|
+
- **Same selector repeated** → OR: `!ARD !ZDF` means ARD or ZDF.
|
|
86
|
+
- **Comma within a selector's value** → AND of words: `#Olympia,Tokio` matches
|
|
87
|
+
topic containing both "Olympia" and "Tokio".
|
|
88
|
+
- **No negation operator.** Exclusion is done client-side with `--exclude`.
|
|
89
|
+
|
|
90
|
+
> Note: the API is case-insensitive and flexible with umlauts
|
|
91
|
+
> (`ö` ≈ `oe` ≈ `OE`).
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
94
|
+
|
|
95
|
+
### `mvw search`
|
|
96
|
+
|
|
97
|
+
Search MVW and display a Rich results table.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
mvw search QUERY
|
|
101
|
+
[--channel C] [--topic T] [--title T] [--description D]
|
|
102
|
+
[--min-duration MIN] [--max-duration MAX]
|
|
103
|
+
[--sort timestamp|duration|channel] [--order asc|desc]
|
|
104
|
+
[--future] [--limit N] [--offset N] [--json]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Option | Default | Description |
|
|
108
|
+
|--------|---------|-------------|
|
|
109
|
+
| `--channel` | — | Filter by channel (structured flag, not query syntax) |
|
|
110
|
+
| `--topic` | — | Filter by topic |
|
|
111
|
+
| `--title` | — | Filter by title |
|
|
112
|
+
| `--description` | — | Filter by description |
|
|
113
|
+
| `--min-duration` | — | Minimum duration in minutes |
|
|
114
|
+
| `--max-duration` | — | Maximum duration in minutes |
|
|
115
|
+
| `--sort` | `timestamp` | Sort field |
|
|
116
|
+
| `--order` | `desc` | Sort order (`asc` or `desc`) |
|
|
117
|
+
| `--future` | off | Include not-yet-aired entries |
|
|
118
|
+
| `--limit` | 15 | Number of results to fetch |
|
|
119
|
+
| `--offset` | 0 | Pagination offset |
|
|
120
|
+
| `--json` | off | Emit raw JSON to stdout (scripting-friendly) |
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
mvw search "#Tatort !ARD >80"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `mvw download`
|
|
129
|
+
|
|
130
|
+
Search and download matching entries. Run `--dry-run` first to preview the
|
|
131
|
+
exact file tree before downloading anything.
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
mvw download QUERY
|
|
135
|
+
[--channel C] [--topic T] [--title T]
|
|
136
|
+
[--min-duration MIN] [--max-duration MAX]
|
|
137
|
+
[--season] [--dry-run]
|
|
138
|
+
[--resolution low|medium|high|best]
|
|
139
|
+
[--output DIR] [-o DIR] [--template STR]
|
|
140
|
+
[--exclude TERM ...] [--dedup] [--latest-season]
|
|
141
|
+
[--season-number N] [--subtitles] [--limit N]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Option | Default | Description |
|
|
145
|
+
|--------|---------|-------------|
|
|
146
|
+
| `--channel` | — | Filter by channel |
|
|
147
|
+
| `--topic` | — | Filter by topic |
|
|
148
|
+
| `--title` | — | Filter by title |
|
|
149
|
+
| `--min-duration` | — | Minimum duration in minutes |
|
|
150
|
+
| `--max-duration` | — | Maximum duration in minutes |
|
|
151
|
+
| `--season` | off | Group into Plex season folders using `S##E##` numbering |
|
|
152
|
+
| `--dry-run` | off | Preview the file tree and source URLs; download nothing |
|
|
153
|
+
| `--resolution` | `best` | Resolution preference: `low`, `medium`, `high`, or `best` |
|
|
154
|
+
| `--output`, `-o` | config default | Output directory |
|
|
155
|
+
| `--template` | Plex default | Custom filename template (see below) |
|
|
156
|
+
| `--exclude` | — | Regex to exclude entries from title/topic/description (repeatable) |
|
|
157
|
+
| `--dedup` | off | Remove near-duplicate entries, keeping the highest-quality copy |
|
|
158
|
+
| `--latest-season` | off | Keep only entries from the highest detected season |
|
|
159
|
+
| `--season-number` | — | Override detected season number |
|
|
160
|
+
| `--subtitles` | off | Also fetch subtitle files alongside each video |
|
|
161
|
+
| `--limit` | 200 | Maximum number of entries to resolve |
|
|
162
|
+
|
|
163
|
+
#### Filename template
|
|
164
|
+
|
|
165
|
+
The default template produces Plex/Jellyfin-compatible paths:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
{series} ({year})/Season {s:02d}/{series} ({year}) - s{s:02d}e{e:02d} - {ep_title} [{res}].{ext}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Override with `--template`. Available tokens:
|
|
172
|
+
|
|
173
|
+
| Token | Value |
|
|
174
|
+
|-------|-------|
|
|
175
|
+
| `{series}` | Topic (show name) |
|
|
176
|
+
| `{year}` | Broadcast year |
|
|
177
|
+
| `{s}` | Season number (supports `:02d` formatting) |
|
|
178
|
+
| `{e}` | Episode number (supports `:02d` formatting) |
|
|
179
|
+
| `{ep_title}` | Cleaned episode title |
|
|
180
|
+
| `{res}` | Resolution label (see note below) |
|
|
181
|
+
| `{channel}` | Broadcaster |
|
|
182
|
+
| `{date}` | Broadcast date (`YYYY-MM-DD`) |
|
|
183
|
+
| `{ext}` | File extension |
|
|
184
|
+
|
|
185
|
+
**`{res}` label note:** MVW exposes only three tiers (`low` / `medium` / `high`),
|
|
186
|
+
not measured pixel heights. The `{res}` token maps these to conventional labels —
|
|
187
|
+
`high → "1080p"`, `medium → "720p"`, `low → "480p"` — because Plex parses these
|
|
188
|
+
and they reflect typical public-broadcast encodes. These are labels, not
|
|
189
|
+
guarantees of exact resolution.
|
|
190
|
+
|
|
191
|
+
#### ffmpeg requirement for HLS
|
|
192
|
+
|
|
193
|
+
Some entries serve `.m3u8` HLS playlists instead of direct `.mp4` files. Those
|
|
194
|
+
are downloaded via `ffmpeg -i <url> -c copy <dest>`. If ffmpeg is not on your
|
|
195
|
+
PATH and an HLS entry is encountered, `mvw` exits with code 4 and prints an
|
|
196
|
+
install hint. Install from <https://ffmpeg.org/download.html>.
|
|
197
|
+
|
|
198
|
+
#### Flagship example: Feuer und Flamme
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Preview the newest season, no audio description, deduped
|
|
202
|
+
mvw download "#Feuer und Flamme" --season --latest-season --dedup \
|
|
203
|
+
--exclude Audiodeskription --exclude "Gebärdensprache" \
|
|
204
|
+
--output ~/Media/TV --dry-run
|
|
205
|
+
|
|
206
|
+
# Then download for real in best resolution
|
|
207
|
+
mvw download "#Feuer und Flamme" --season --latest-season --dedup \
|
|
208
|
+
--exclude Audiodeskription --output ~/Media/TV
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `mvw info`
|
|
212
|
+
|
|
213
|
+
Show a Rich detail panel for the first match of a query.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
mvw info QUERY
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Displays: topic, title, description, channel, aired datetime, duration, size,
|
|
220
|
+
available resolutions with URLs, subtitle URL, website URL, and detected
|
|
221
|
+
season/episode.
|
|
222
|
+
|
|
223
|
+
### `mvw config`
|
|
224
|
+
|
|
225
|
+
Manage persistent configuration stored in `config.toml`
|
|
226
|
+
(location: `platformdirs.user_config_dir("mvw")`).
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
mvw config show # Print the effective config (key = value)
|
|
230
|
+
mvw config set KEY VALUE # Write a key to config.toml
|
|
231
|
+
mvw config path # Print the path to config.toml
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Available keys: `download_dir`, `template`, `resolution`, `user_agent`,
|
|
235
|
+
`page_size`, `request_timeout`.
|
|
236
|
+
|
|
237
|
+
CLI flags always override config file values, which override built-in defaults.
|
|
238
|
+
|
|
239
|
+
## Exit codes
|
|
240
|
+
|
|
241
|
+
| Code | Condition |
|
|
242
|
+
|------|-----------|
|
|
243
|
+
| 0 | Success or no results |
|
|
244
|
+
| 2 | API error (non-null `err`), HTTP error, or network/timeout after retries |
|
|
245
|
+
| 4 | HLS entry encountered but ffmpeg is not installed |
|
|
246
|
+
| 5 | Partial/interrupted download failure |
|
|
247
|
+
|
|
248
|
+
## Running tests
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# Unit and mocked tests (default)
|
|
252
|
+
uv run pytest -q
|
|
253
|
+
|
|
254
|
+
# Include the live API test (requires network access)
|
|
255
|
+
uv run pytest -m live tests/test_live.py -v
|
|
256
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
mvw/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
mvw/api.py,sha256=umJQipBJf28uOxaAfaFObg46t_f6k1wfx2ihUffFiBo,2434
|
|
3
|
+
mvw/cli.py,sha256=jfAFGccyHEqrr_ufOE2_PaYYkXjAQfzCT8C5Ovy-3mc,8519
|
|
4
|
+
mvw/config.py,sha256=sHQjkHt22Z0-2sRO6Wdlo8l-F283Y3Nzs7KtGqzyjdM,1718
|
|
5
|
+
mvw/display.py,sha256=96Plt3Nzcb549r7Df4CT6YNVpFqrxpjqfwsgkXatgew,2466
|
|
6
|
+
mvw/download.py,sha256=MCbXATjGv4ZDH4mbaAmM4IuybW_r4IEqR8AG4Qm8EE8,2719
|
|
7
|
+
mvw/episodes.py,sha256=QjrRf3L2ihgzAJNWDqzaC2g1N5rOY5x2LDsAGEbhEv0,2932
|
|
8
|
+
mvw/filters.py,sha256=mySPklWFk6l94YGC9duoe1qw-YXwTqC8xvJ2_iquk3w,1861
|
|
9
|
+
mvw/models.py,sha256=GfgtqdOgWunAkTk5e2HocedkagTRBIqz4_6TcAYKkVo,3287
|
|
10
|
+
mvw/naming.py,sha256=sP41a395jwookebM360G4j6zYx2scosw51m1yv3fQ5Y,1462
|
|
11
|
+
mvw/query.py,sha256=l__5Ae0xtchr1iRU2ng-TmEfJZafZyU-BYK_s7zmc5A,3495
|
|
12
|
+
mvw_cli-0.1.0.dist-info/METADATA,sha256=3appoB6h8ZHdqSEa7dkyU7deYNK8hasjcbK5bYjSGXc,8635
|
|
13
|
+
mvw_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
mvw_cli-0.1.0.dist-info/entry_points.txt,sha256=YkBabN7J7BLpn37C8tmFf1E7kWUHBAo11wxjhRspSi8,36
|
|
15
|
+
mvw_cli-0.1.0.dist-info/licenses/LICENSE,sha256=tT1uuoa2LujVevO7EDuNARVNvTUzbEhhOuAnNGsx2cQ,1071
|
|
16
|
+
mvw_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Boettinger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|