mediathek-mcp 0.1.0__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.
@@ -0,0 +1,20 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # required for PyPI trusted publishing (OIDC)
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,61 @@
1
+ name: Publish to MCP Registry
2
+
3
+ # Official MCP Registry (registry.modelcontextprotocol.io) via GitHub OIDC.
4
+ # No secret, no interactive login: the runner's OIDC token proves ownership
5
+ # of the io.github.mohammad-abbasi-sh/* namespace.
6
+ # Refs: https://github.com/modelcontextprotocol/registry
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ id-token: write # required for OIDC -> MCP Registry
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+
22
+ - name: Sync server.json version to tag
23
+ # Only sync when running on a version tag (v*). On workflow_dispatch
24
+ # (ref = refs/heads/main) keep the version committed in server.json.
25
+ run: |
26
+ VER=""
27
+ if [[ "$GITHUB_REF" == refs/tags/* ]]; then
28
+ TAG="${GITHUB_REF#refs/tags/}"
29
+ VER="${TAG#v}"
30
+ fi
31
+ if [ -n "$VER" ]; then
32
+ python3 - "$VER" << 'EOF'
33
+ import json, sys
34
+ ver = sys.argv[1]
35
+ d = json.load(open('server.json'))
36
+ d['version'] = ver
37
+ for pkg in d.get('packages', []):
38
+ pkg['version'] = ver
39
+ json.dump(d, open('server.json', 'w'), indent=2, ensure_ascii=False)
40
+ print('server.json version ->', ver)
41
+ EOF
42
+ else
43
+ echo "No version tag ($GITHUB_REF); keeping server.json version as-is"
44
+ fi
45
+
46
+ - name: Install mcp-publisher
47
+ run: |
48
+ curl -sL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz" -o /tmp/mcp-publisher.tar.gz
49
+ tar -xzf /tmp/mcp-publisher.tar.gz -C /tmp
50
+ sudo mv /tmp/mcp-publisher /usr/local/bin/mcp-publisher
51
+ mcp-publisher --version
52
+
53
+ - name: Validate server.json
54
+ run: mcp-publisher validate server.json
55
+
56
+ - name: Publish to MCP Registry (OIDC)
57
+ run: |
58
+ mcp-publisher login github-oidc
59
+ mcp-publisher publish server.json
60
+ # A registry outage must never break the release; re-run from Actions if needed.
61
+ continue-on-error: true
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammad Abbasi
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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: mediathek-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for the German public broadcasters' Mediathek — search ARD, ZDF, 3sat, ARTE, Phoenix and more via MediathekViewWeb
5
+ Author: Mohammad Abbasi
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: mcp<2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # mediathek-mcp 📺
14
+
15
+ mcp-name: io.github.mohammad-abbasi-sh/mediathek-mcp
16
+
17
+ Unofficial **read-only** MCP server for the **German Mediathek** via the free [MediathekViewWeb](https://mediathekviewweb.de) API — search the programmes of ARD, ZDF, 3sat, ARTE, Phoenix, tagesschau24, KiKA and the regional channels. No key, no login.
18
+
19
+ > Built by Mohammad Abbasi as part of the "Germany MCP factory" (Sepehr-style pipeline). Not affiliated with MediathekView, ARD or ZDF.
20
+
21
+ ## For AI agents
22
+
23
+ Add to your MCP client config (stdio):
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "mediathek": {
29
+ "command": "uvx",
30
+ "args": ["mediathek-mcp"]
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ Or run locally: `python -m mediathek_mcp.server` (needs `mcp<2`, `httpx`).
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `mediathek_search_programmes` | Search programmes by title, topic or description — `query` (e.g. "Tagesschau"), optional `channel` (e.g. "ZDF"), `limit` 1..50 |
43
+ | `mediathek_latest_broadcasts` | Newest programmes across all channels, newest first — `limit` 1..50 |
44
+ | `mediathek_by_channel` | Newest programmes of one channel — `channel` (e.g. "ZDF", "ARD", "3sat", "ARTE.DE", "PHOENIX"), `limit` 1..50 |
45
+ | `mediathek_by_topic` | All episodes of one show/series — `topic` (e.g. "heute journal"), `limit` 1..50 |
46
+ | `mediathek_programme_detail` | Full detail of one programme: all video URLs (low/standard/HD), subtitle URL, website link — `title` (exact), optional `channel` |
47
+ | `mediathek_list_channels` | Channels in the film list (curated list — the API has no channel-list endpoint) |
48
+
49
+ Example output (`mediathek_search_programmes` / "Tagesschau"):
50
+
51
+ ```json
52
+ {"query": "Tagesschau", "channel": null, "total_results": 8823, "count": 3,
53
+ "film_list_updated": "2026-11-09T10:19:00+00:00", "programmes": [
54
+ {"id": "+sU90jI0m7KCrRqsj5aYEh9Owg7tr+3K9IRl39v+A8o=",
55
+ "channel": "tagesschau24", "topic": "tagesschau in 100 Sekunden",
56
+ "title": "tagesschau in 100 Sekunden",
57
+ "description": "tagesschau in 100 Sekunden",
58
+ "broadcast": "2026-11-09T04:43:00+00:00", "duration_seconds": 170,
59
+ "duration": "2:50",
60
+ "video_url": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxl.h264.mp4",
61
+ "video_url_hd": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxxl.h264.mp4",
62
+ "video_url_low": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0257-3000.webm.h264.mp4",
63
+ "subtitle_url": "", "website_url": "https://www.ardmediathek.de/video/...",
64
+ "size_bytes": 40894464},
65
+ ...]}
66
+ ```
67
+
68
+ Notes:
69
+ - The film list holds ~700,000 programmes and is updated several times a day; results are newest first.
70
+ - "Latest broadcasts" searches the most common letter across title/topic/description — the API returns nothing for an empty query and has no wildcard, so this is effectively the newest programmes.
71
+ - Channel filtering in `mediathek_search_programmes` is applied client-side over the newest 50 text matches (the API has no AND query); `mediathek_by_channel` queries the channel directly.
72
+ - `total_results` is capped at 10,000 by the API for very broad queries.
73
+ - Video URLs are direct MP4 links (low / standard / HD); `subtitle_url` is empty when no subtitles exist.
74
+ - Data: MediathekViewWeb public API — no key, no login, read-only.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
80
+ .venv/bin/python -m pytest tests/ -q
81
+ ```
82
+
83
+ - `tests/test_domain.py` — offline, parses real captured API fixtures (19 tests)
84
+ - `tests/test_live.py` — cautious live requests against mediathekviewweb.de (6 tests)
85
+
86
+ Tests: 25/25 green (19 offline + 6 live, 2026-11-09).
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,57 @@
1
+ # mediathek-mcp 📺
2
+
3
+ **Die deutsche Mediathek** über die kostenlose [MediathekViewWeb](https://mediathekviewweb.de)-API — inoffizieller, **nur lesender** MCP-Server: Sendungen von ARD, ZDF, 3sat, ARTE, Phoenix, tagesschau24, KiKA und den Regionalsendern durchsuchen. Kein Schlüssel, keine Anmeldung.
4
+
5
+ > Gebaut von Mohammad Abbasi im Rahmen der „Germany MCP factory“ (Sepehr-Stil). Keine Verbindung zu MediathekView, ARD oder ZDF.
6
+
7
+ ## Für KI-Agenten
8
+
9
+ In die MCP-Konfiguration eintragen (stdio):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "mediathek": {
15
+ "command": "uvx",
16
+ "args": ["mediathek-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ Oder lokal starten: `python -m mediathek_mcp.server` (benötigt `mcp<2`, `httpx`).
23
+
24
+ ## Werkzeuge
25
+
26
+ | Werkzeug | Beschreibung |
27
+ |------|-------------|
28
+ | `mediathek_search_programmes` | Sendungen nach Titel, Thema oder Beschreibung suchen — `query` (z. B. „Tagesschau“), optional `channel` (z. B. „ZDF“), `limit` 1..50 |
29
+ | `mediathek_latest_broadcasts` | Neueste Sendungen aller Sender, neueste zuerst — `limit` 1..50 |
30
+ | `mediathek_by_channel` | Neueste Sendungen eines Senders — `channel` (z. B. „ZDF“, „ARD“, „3sat“, „ARTE.DE“, „PHOENIX“), `limit` 1..50 |
31
+ | `mediathek_by_topic` | Alle Folgen einer Sendung/Serie — `topic` (z. B. „heute journal“), `limit` 1..50 |
32
+ | `mediathek_programme_detail` | Details einer Sendung: alle Video-URLs (niedrig/Standard/HD), Untertitel-URL, Webseiten-Link — `title` (exakt), optional `channel` |
33
+ | `mediathek_list_channels` | Sender der Filmliste (kuratierte Liste — die API hat keinen Senderlisten-Endpunkt) |
34
+
35
+ Hinweise:
36
+ - Die Filmliste enthält ca. 700.000 Sendungen und wird mehrmals täglich aktualisiert; die Ergebnisse sind neueste zuerst sortiert.
37
+ - „Neueste Sendungen“ sucht nach dem häufigsten Buchstaben in Titel/Thema/Beschreibung — die API liefert bei leerer Anfrage nichts und kennt keinen Platzhalter; das Ergebnis sind praktisch die neuesten Sendungen.
38
+ - Die Senderfilterung in `mediathek_search_programmes` erfolgt clientseitig über die neuesten 50 Texttreffer (die API kennt keine UND-Anfrage); `mediathek_by_channel` fragt den Sender direkt ab.
39
+ - `total_results` ist bei sehr breiten Anfragen von der API auf 10.000 begrenzt.
40
+ - Video-URLs sind direkte MP4-Links (niedrig / Standard / HD); `subtitle_url` ist leer, wenn keine Untertitel existieren.
41
+ - Daten: öffentliche MediathekViewWeb-API — kein Schlüssel, keine Anmeldung, nur lesend.
42
+
43
+ ## Entwicklung
44
+
45
+ ```bash
46
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
47
+ .venv/bin/python -m pytest tests/ -q
48
+ ```
49
+
50
+ - `tests/test_domain.py` — offline, mit echten aufgezeichneten API-Antworten (19 Tests)
51
+ - `tests/test_live.py` — wenige vorsichtige Live-Anfragen an mediathekviewweb.de (6 Tests)
52
+
53
+ Tests: 25/25 grün (19 offline + 6 live, 09.11.2026).
54
+
55
+ ## Lizenz
56
+
57
+ MIT
@@ -0,0 +1,57 @@
1
+ # mediathek-mcp 📺
2
+
3
+ سرور MCP **غیررسمی و فقط‌خواندنی** برای **مدیاتک آلمان** از طریق API رایگان [MediathekViewWeb](https://mediathekviewweb.de) — جست‌وجوی برنامه‌های ARD ،ZDF ،3sat ،ARTE ،Phoenix ،tagesschau24 ،KiKA و شبکه‌های منطقه‌ای. بدون کلید، بدون لاگین.
4
+
5
+ > ساختهٔ محمد عباسی در چارچوب «کارخانهٔ MCP آلمان» (به سبک سپهر). وابسته به MediathekView یا ARD یا ZDF نیست.
6
+
7
+ ## برای ایجنت‌های هوش مصنوعی
8
+
9
+ به تنظیمات MCP اضافه کنید (stdio):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "mediathek": {
15
+ "command": "uvx",
16
+ "args": ["mediathek-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ یا اجرای محلی: `python -m mediathek_mcp.server` (نیازمند `mcp<2` و `httpx`).
23
+
24
+ ## ابزارها
25
+
26
+ | ابزار | توضیح |
27
+ |------|------|
28
+ | `mediathek_search_programmes` | جست‌وجوی برنامه‌ها با عنوان، موضوع یا توضیح — `query` (مثل «Tagesschau»)، `channel` اختیاری (مثل «ZDF»)، `limit` از ۱ تا ۵۰ |
29
+ | `mediathek_latest_broadcasts` | جدیدترین برنامه‌های همهٔ شبکه‌ها، از جدید به قدیم — `limit` از ۱ تا ۵۰ |
30
+ | `mediathek_by_channel` | جدیدترین برنامه‌های یک شبکه — `channel` (مثل «ZDF»، «ARD»، «3sat»، «ARTE.DE»، «PHOENIX»)، `limit` از ۱ تا ۵۰ |
31
+ | `mediathek_by_topic` | همهٔ قسمت‌های یک برنامه/سریال — `topic` (مثل «heute journal»)، `limit` از ۱ تا ۵۰ |
32
+ | `mediathek_programme_detail` | جزئیات کامل یک برنامه: همهٔ لینک‌های ویدیو (کم‌کیفیت/معمولی/HD)، لینک زیرنویس و لینک وب‌سایت — `title` (دقیق)، `channel` اختیاری |
33
+ | `mediathek_list_channels` | شبکه‌های فهرست فیلم (فهرست انتخاب‌شده — API اندپوینت فهرست شبکه ندارد) |
34
+
35
+ نکته‌ها:
36
+ - فهرست فیلم حدود ۷۰۰٬۰۰۰ برنامه دارد و چند بار در روز به‌روز می‌شود؛ نتایج از جدید به قدیم مرتب‌اند.
37
+ - «جدیدترین برنامه‌ها» با جست‌وجوی پرتکرارترین حرف در عنوان/موضوع/توضیح کار می‌کند — API برای پرس‌وجوی خالی چیزی برنمی‌گرداند و کاراکتر جایگزین ندارد؛ نتیجه عملاً جدیدترین برنامه‌هاست.
38
+ - فیلتر شبکه در `mediathek_search_programmes` سمت کلاینت روی جدیدترین ۵۰ نتیجهٔ متنی اعمال می‌شود (API پرس‌وجوی AND ندارد)؛ `mediathek_by_channel` مستقیم شبکه را می‌پرسد.
39
+ - مقدار `total_results` برای پرس‌وجوهای خیلی گسترده از طرف API به ۱۰٬۰۰۰ محدود است.
40
+ - لینک‌های ویدیو مستقیم MP4 هستند (کم‌کیفیت / معمولی / HD)؛ اگر زیرنویس نباشد `subtitle_url` خالی است.
41
+ - داده: API عمومی MediathekViewWeb — بدون کلید، بدون لاگین، فقط‌خواندنی.
42
+
43
+ ## توسعه
44
+
45
+ ```bash
46
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
47
+ .venv/bin/python -m pytest tests/ -q
48
+ ```
49
+
50
+ - `tests/test_domain.py` — آفلاین، با پاسخ‌های واقعی ضبط‌شده از API (۱۹ تست)
51
+ - `tests/test_live.py` — چند درخواست زندهٔ محتاطانه به mediathekviewweb.de (۶ تست)
52
+
53
+ تست‌ها: ۲۵ از ۲۵ سبز (۱۹ آفلاین + ۶ زنده، ۲۰۲۶-۱۱-۰۹).
54
+
55
+ ## مجوز
56
+
57
+ MIT
@@ -0,0 +1,78 @@
1
+ # mediathek-mcp 📺
2
+
3
+ mcp-name: io.github.mohammad-abbasi-sh/mediathek-mcp
4
+
5
+ Unofficial **read-only** MCP server for the **German Mediathek** via the free [MediathekViewWeb](https://mediathekviewweb.de) API — search the programmes of ARD, ZDF, 3sat, ARTE, Phoenix, tagesschau24, KiKA and the regional channels. No key, no login.
6
+
7
+ > Built by Mohammad Abbasi as part of the "Germany MCP factory" (Sepehr-style pipeline). Not affiliated with MediathekView, ARD or ZDF.
8
+
9
+ ## For AI agents
10
+
11
+ Add to your MCP client config (stdio):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "mediathek": {
17
+ "command": "uvx",
18
+ "args": ["mediathek-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Or run locally: `python -m mediathek_mcp.server` (needs `mcp<2`, `httpx`).
25
+
26
+ ## Tools
27
+
28
+ | Tool | Description |
29
+ |------|-------------|
30
+ | `mediathek_search_programmes` | Search programmes by title, topic or description — `query` (e.g. "Tagesschau"), optional `channel` (e.g. "ZDF"), `limit` 1..50 |
31
+ | `mediathek_latest_broadcasts` | Newest programmes across all channels, newest first — `limit` 1..50 |
32
+ | `mediathek_by_channel` | Newest programmes of one channel — `channel` (e.g. "ZDF", "ARD", "3sat", "ARTE.DE", "PHOENIX"), `limit` 1..50 |
33
+ | `mediathek_by_topic` | All episodes of one show/series — `topic` (e.g. "heute journal"), `limit` 1..50 |
34
+ | `mediathek_programme_detail` | Full detail of one programme: all video URLs (low/standard/HD), subtitle URL, website link — `title` (exact), optional `channel` |
35
+ | `mediathek_list_channels` | Channels in the film list (curated list — the API has no channel-list endpoint) |
36
+
37
+ Example output (`mediathek_search_programmes` / "Tagesschau"):
38
+
39
+ ```json
40
+ {"query": "Tagesschau", "channel": null, "total_results": 8823, "count": 3,
41
+ "film_list_updated": "2026-11-09T10:19:00+00:00", "programmes": [
42
+ {"id": "+sU90jI0m7KCrRqsj5aYEh9Owg7tr+3K9IRl39v+A8o=",
43
+ "channel": "tagesschau24", "topic": "tagesschau in 100 Sekunden",
44
+ "title": "tagesschau in 100 Sekunden",
45
+ "description": "tagesschau in 100 Sekunden",
46
+ "broadcast": "2026-11-09T04:43:00+00:00", "duration_seconds": 170,
47
+ "duration": "2:50",
48
+ "video_url": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxl.h264.mp4",
49
+ "video_url_hd": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxxl.h264.mp4",
50
+ "video_url_low": "https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0257-3000.webm.h264.mp4",
51
+ "subtitle_url": "", "website_url": "https://www.ardmediathek.de/video/...",
52
+ "size_bytes": 40894464},
53
+ ...]}
54
+ ```
55
+
56
+ Notes:
57
+ - The film list holds ~700,000 programmes and is updated several times a day; results are newest first.
58
+ - "Latest broadcasts" searches the most common letter across title/topic/description — the API returns nothing for an empty query and has no wildcard, so this is effectively the newest programmes.
59
+ - Channel filtering in `mediathek_search_programmes` is applied client-side over the newest 50 text matches (the API has no AND query); `mediathek_by_channel` queries the channel directly.
60
+ - `total_results` is capped at 10,000 by the API for very broad queries.
61
+ - Video URLs are direct MP4 links (low / standard / HD); `subtitle_url` is empty when no subtitles exist.
62
+ - Data: MediathekViewWeb public API — no key, no login, read-only.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ python3 -m venv .venv && .venv/bin/pip install "mcp<2" httpx pytest
68
+ .venv/bin/python -m pytest tests/ -q
69
+ ```
70
+
71
+ - `tests/test_domain.py` — offline, parses real captured API fixtures (19 tests)
72
+ - `tests/test_live.py` — cautious live requests against mediathekviewweb.de (6 tests)
73
+
74
+ Tests: 25/25 green (19 offline + 6 live, 2026-11-09).
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mediathek-mcp"
7
+ version = "0.1.0"
8
+ description = "Unofficial read-only MCP server for the German public broadcasters' Mediathek — search ARD, ZDF, 3sat, ARTE, Phoenix and more via MediathekViewWeb"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "mcp<2",
14
+ "httpx>=0.27",
15
+ ]
16
+ authors = [{ name = "Mohammad Abbasi" }]
17
+
18
+ [project.scripts]
19
+ mediathek-mcp = "mediathek_mcp.server:main"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/mediathek_mcp"]
23
+
24
+ [tool.pytest.ini_options]
25
+ markers = ["live: tests that hit the real MediathekViewWeb API"]
26
+
@@ -0,0 +1 @@
1
+ {"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","description":"Unofficial read-only MCP server for the German Mediathek — search ARD, ZDF, 3sat, ARTE & more.","name":"io.github.mohammad-abbasi-sh/mediathek-mcp","packages":[{"identifier":"mediathek-mcp","registryBaseUrl":"https://pypi.org","registryType":"pypi","transport":{"type":"stdio"},"version":"0.1.0"}],"repository":{"source":"github","url":"https://github.com/mohammad-abbasi-sh/mediathek-mcp"},"title":"Mediathek — German public broadcasters' programmes","version":"0.1.0","websiteUrl":"https://github.com/mohammad-abbasi-sh/mediathek-mcp"}
@@ -0,0 +1,9 @@
1
+ """mediathek_mcp — unofficial read-only MCP server for the German Mediathek.
2
+
3
+ Searches the programmes of the German public broadcasters (ARD, ZDF, 3sat,
4
+ ARTE, Phoenix, tagesschau24, KiKA and the regional channels) through the
5
+ free MediathekViewWeb API (mediathekviewweb.de) — no key, no login.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,118 @@
1
+ """Domain logic: shape MediathekViewWeb responses into compact dicts (stdlib only)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+
6
+ # Channels in the MediathekView film list, as seen in live probes and the
7
+ # project's public channel list. The API itself has no channel-list
8
+ # endpoint, so this is a curated list — search_by_channel passes any
9
+ # other channel name through to the API unchanged.
10
+ CHANNELS = [
11
+ {"channel": "ARD", "name": "Das Erste"},
12
+ {"channel": "ZDF", "name": "Zweites Deutsches Fernsehen"},
13
+ {"channel": "3sat", "name": "3sat"},
14
+ {"channel": "ARTE.DE", "name": "ARTE (deutsch)"},
15
+ {"channel": "ARTE.FR", "name": "ARTE (französisch)"},
16
+ {"channel": "PHOENIX", "name": "Phoenix"},
17
+ {"channel": "tagesschau24", "name": "tagesschau24"},
18
+ {"channel": "KiKA", "name": "Kinderkanal"},
19
+ {"channel": "ONE", "name": "ONE"},
20
+ {"channel": "WDR", "name": "Westdeutscher Rundfunk"},
21
+ {"channel": "NDR", "name": "Norddeutscher Rundfunk"},
22
+ {"channel": "BR", "name": "Bayerischer Rundfunk"},
23
+ {"channel": "SWR", "name": "Südwestrundfunk"},
24
+ {"channel": "MDR", "name": "Mitteldeutscher Rundfunk"},
25
+ {"channel": "HR", "name": "Hessischer Rundfunk"},
26
+ {"channel": "RBB", "name": "Rundfunk Berlin-Brandenburg"},
27
+ {"channel": "SR", "name": "Saarländischer Rundfunk"},
28
+ ]
29
+
30
+ _KNOWN = {c["channel"].lower(): c["channel"] for c in CHANNELS}
31
+
32
+ # Broad single-letter query used for "latest broadcasts": the API returns
33
+ # nothing for an empty query and no wildcard exists, so we search the
34
+ # most common letter across title/topic/description, newest first. The
35
+ # result is effectively the newest programmes in the film list.
36
+ LATEST_QUERY = "e"
37
+ LATEST_FIELDS = ["title", "topic", "description"]
38
+
39
+
40
+ def validate_limit(limit: int) -> int:
41
+ """Clamp/validate the page size (API-side max is 50)."""
42
+ if not 1 <= limit <= 50:
43
+ raise ValueError(f"limit out of range: {limit} (expected 1..50)")
44
+ return limit
45
+
46
+
47
+ def normalise_channel(channel: str) -> str:
48
+ """Canonical capitalisation for known channels; unknown names pass through."""
49
+ key = (channel or "").strip()
50
+ if not key:
51
+ raise ValueError("channel must not be empty")
52
+ return _KNOWN.get(key.lower(), key)
53
+
54
+
55
+ def _iso(ts) -> str | None:
56
+ if not ts:
57
+ return None
58
+ try:
59
+ return datetime.fromtimestamp(int(ts), tz=timezone.utc).isoformat()
60
+ except (TypeError, ValueError):
61
+ return None
62
+
63
+
64
+ def _duration_hms(seconds) -> str | None:
65
+ if seconds is None:
66
+ return None
67
+ try:
68
+ s = int(seconds)
69
+ except (TypeError, ValueError):
70
+ return None
71
+ h, rem = divmod(s, 3600)
72
+ m, sec = divmod(rem, 60)
73
+ return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
74
+
75
+
76
+ def compact_programme(p: dict) -> dict:
77
+ """One programme, compact: no internal ids beyond the API's own id."""
78
+ return {
79
+ "id": p.get("id") or "",
80
+ "channel": p.get("channel") or "",
81
+ "topic": p.get("topic") or "",
82
+ "title": p.get("title") or "",
83
+ "description": p.get("description") or "",
84
+ "broadcast": _iso(p.get("timestamp")),
85
+ "duration_seconds": p.get("duration"),
86
+ "duration": _duration_hms(p.get("duration")),
87
+ "video_url": p.get("url_video") or "",
88
+ "video_url_hd": p.get("url_video_hd") or "",
89
+ "video_url_low": p.get("url_video_low") or "",
90
+ "subtitle_url": p.get("url_subtitle") or "",
91
+ "website_url": p.get("url_website") or "",
92
+ "size_bytes": p.get("size"),
93
+ }
94
+
95
+
96
+ def compact_results(result: dict) -> dict:
97
+ """The API's result object, compacted for tool output."""
98
+ info = result.get("queryInfo") or {}
99
+ return {
100
+ "total_results": info.get("totalResults"),
101
+ "film_list_updated": _iso(info.get("filmlisteTimestamp")),
102
+ "count": len(result.get("results") or []),
103
+ "programmes": [compact_programme(p) for p in result.get("results") or []],
104
+ }
105
+
106
+
107
+ def find_exact_title(results: list, title: str, channel: str | None) -> dict | None:
108
+ """First result whose title matches exactly (case-insensitive), optionally
109
+ restricted to one channel. Returns the raw API entry or None."""
110
+ want = (title or "").strip().lower()
111
+ chan = (channel or "").strip().lower()
112
+ for p in results or []:
113
+ if (p.get("title") or "").strip().lower() != want:
114
+ continue
115
+ if chan and (p.get("channel") or "").strip().lower() != chan:
116
+ continue
117
+ return p
118
+ return None
@@ -0,0 +1,114 @@
1
+ """HTTP layer for mediathek_mcp — plain httpx POSTs against the free
2
+ MediathekViewWeb API (mediathekviewweb.de/api/query).
3
+
4
+ No key, no login. The API is a community service over the public film
5
+ list of the German public broadcasters (ARD, ZDF, 3sat, ARTE, Phoenix
6
+ and the regional channels). One POST per query; the body selects the
7
+ fields to search, sort order and page size.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import ssl
13
+
14
+ import httpx
15
+
16
+ BASE = "https://mediathekviewweb.de/api/query"
17
+ UA = (
18
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
19
+ "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
20
+ )
21
+ TIMEOUT = 25.0
22
+ MAX_SIZE = 50
23
+
24
+
25
+ class MediathekError(RuntimeError):
26
+ pass
27
+
28
+
29
+ def _verify():
30
+ """TLS trust store: the OS bundle when present, else httpx/certifi.
31
+
32
+ The OS bundle matters behind MITM egress proxies (their CA is in the
33
+ system store, not in certifi) and is the right default on Linux
34
+ anyway; on systems without it we fall back to certifi.
35
+ """
36
+ for path in (
37
+ "/etc/ssl/certs/ca-certificates.crt",
38
+ ssl.get_default_verify_paths().openssl_cafile,
39
+ ):
40
+ if path and os.path.exists(path):
41
+ return ssl.create_default_context(cafile=path)
42
+ return True
43
+
44
+
45
+ def _client() -> httpx.Client:
46
+ # NOTE: we read the proxy URL from the environment but never log it.
47
+ # trust_env=False because httpx fails to parse bracketed IPv6 entries
48
+ # (e.g. [::1]) that some environments put in no_proxy.
49
+ proxy = os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
50
+ return httpx.Client(
51
+ headers={"User-Agent": UA, "Content-Type": "application/json"},
52
+ timeout=TIMEOUT,
53
+ proxy=proxy or None,
54
+ trust_env=False,
55
+ verify=_verify(),
56
+ )
57
+
58
+
59
+ def query(
60
+ queries: list[dict],
61
+ sort_by: str = "timestamp",
62
+ sort_order: str = "desc",
63
+ size: int = 10,
64
+ offset: int = 0,
65
+ ) -> dict:
66
+ """Run one MediathekViewWeb query and return the ``result`` object.
67
+
68
+ ``queries`` is a list of {"fields": [...], "query": "..."} objects
69
+ (OR-combined by the API). ``sort_by`` is "timestamp" (newest first
70
+ with desc) or "title". Raises MediathekError on any failure.
71
+ """
72
+ if not 1 <= size <= MAX_SIZE:
73
+ raise MediathekError(f"size out of range: {size} (expected 1..{MAX_SIZE})")
74
+ body = {
75
+ "queries": queries,
76
+ "sortBy": sort_by,
77
+ "sortOrder": sort_order,
78
+ "future": False,
79
+ "offset": max(0, offset),
80
+ "size": size,
81
+ }
82
+ try:
83
+ with _client() as c:
84
+ r = c.post(BASE, json=body)
85
+ except httpx.HTTPError as e:
86
+ raise MediathekError(f"network error querying MediathekViewWeb: {e}") from e
87
+ if r.status_code != 200:
88
+ raise MediathekError(
89
+ f"MediathekViewWeb returned HTTP {r.status_code} for {BASE}"
90
+ )
91
+ try:
92
+ data = r.json()
93
+ except ValueError as e:
94
+ raise MediathekError("MediathekViewWeb returned invalid JSON") from e
95
+ if data.get("err"):
96
+ raise MediathekError(f"MediathekViewWeb error: {data['err']}")
97
+ result = data.get("result")
98
+ if not isinstance(result, dict) or not isinstance(result.get("results"), list):
99
+ raise MediathekError("unexpected MediathekViewWeb response shape")
100
+ return result
101
+
102
+
103
+ def search(
104
+ fields: list[str],
105
+ text: str,
106
+ size: int = 10,
107
+ sort_by: str = "timestamp",
108
+ ) -> dict:
109
+ """Single-field-set text search; returns the ``result`` object."""
110
+ return query(
111
+ [{"fields": fields, "query": text}],
112
+ sort_by=sort_by,
113
+ size=size,
114
+ )
@@ -0,0 +1,183 @@
1
+ """MCP server: unofficial read-only access to the German Mediathek via
2
+ MediathekViewWeb (mediathekviewweb.de) — no key, no login."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from . import domain as _dom
11
+ from . import http as _http
12
+
13
+ INSTRUCTIONS = """\
14
+ Unofficial read-only MCP server for the German Mediathek via the free \
15
+ MediathekViewWeb API (mediathekviewweb.de) — no key, no login. It \
16
+ searches the programmes of the German public broadcasters (ARD, ZDF, \
17
+ 3sat, ARTE, Phoenix, tagesschau24, KiKA and the regional channels) in \
18
+ the MediathekView film list (~700,000 entries, updated several times a \
19
+ day). Searches match title, topic and description; results are newest \
20
+ first. "Latest broadcasts" searches the most common letter across \
21
+ title/topic/description because the API returns nothing for an empty \
22
+ query and has no wildcard — the result is effectively the newest \
23
+ programmes. Video URLs are direct MP4 links (low / standard / HD); \
24
+ subtitle_url is empty when no subtitles exist. Built by Mohammad Abbasi \
25
+ (mohammad-abbasi-sh) — Germany MCP factory.\
26
+ """
27
+
28
+ mcp = FastMCP("mediathek-mcp", instructions=INSTRUCTIONS)
29
+
30
+
31
+ def _err(e: Exception) -> str:
32
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
33
+
34
+
35
+ @mcp.tool()
36
+ def mediathek_search_programmes(
37
+ query: str, channel: str | None = None, limit: int = 10
38
+ ) -> str:
39
+ """Search Mediathek programmes by title, topic or description. \
40
+ query: free text, e.g. "Tagesschau" or "Tatort". channel: optional, \
41
+ e.g. "ZDF" (see mediathek_list_channels). limit: 1..50 (default 10)."""
42
+ try:
43
+ if not (query or "").strip():
44
+ return json.dumps(
45
+ {"error": "query must not be empty"},
46
+ ensure_ascii=False,
47
+ )
48
+ lim = _dom.validate_limit(limit)
49
+ if channel:
50
+ # The API OR-combines query objects (no AND), so fetch the
51
+ # newest text matches and filter to the channel client-side.
52
+ chan = _dom.normalise_channel(channel)
53
+ result = _http.search(
54
+ ["title", "topic", "description"], query.strip(), size=50
55
+ )
56
+ filtered = [
57
+ p for p in result.get("results") or []
58
+ if (p.get("channel") or "").lower() == chan.lower()
59
+ ]
60
+ result = {**result, "results": filtered[:lim]}
61
+ out = _dom.compact_results(result)
62
+ out["total_results"] = len(filtered)
63
+ out["note"] = (
64
+ "channel filter applied client-side over the newest 50 "
65
+ "text matches (the API has no AND query)"
66
+ )
67
+ return json.dumps(
68
+ {"query": query.strip(), "channel": chan, **out},
69
+ ensure_ascii=False,
70
+ )
71
+ result = _http.search(
72
+ ["title", "topic", "description"], query.strip(), size=lim
73
+ )
74
+ except (_http.MediathekError, ValueError) as e:
75
+ return _err(e)
76
+ return json.dumps(
77
+ {"query": query.strip(), "channel": channel, **_dom.compact_results(result)},
78
+ ensure_ascii=False,
79
+ )
80
+
81
+
82
+ @mcp.tool()
83
+ def mediathek_latest_broadcasts(limit: int = 10) -> str:
84
+ """Newest programmes across all channels, newest first. \
85
+ limit: 1..50 (default 10)."""
86
+ try:
87
+ lim = _dom.validate_limit(limit)
88
+ result = _http.search(_dom.LATEST_FIELDS, _dom.LATEST_QUERY, size=lim)
89
+ except (_http.MediathekError, ValueError) as e:
90
+ return _err(e)
91
+ return json.dumps(_dom.compact_results(result), ensure_ascii=False)
92
+
93
+
94
+ @mcp.tool()
95
+ def mediathek_by_channel(channel: str, limit: int = 10) -> str:
96
+ """Newest programmes of one channel, newest first. channel: e.g. \
97
+ "ZDF", "ARD", "3sat", "ARTE.DE", "PHOENIX" (see mediathek_list_channels). \
98
+ limit: 1..50 (default 10)."""
99
+ try:
100
+ lim = _dom.validate_limit(limit)
101
+ chan = _dom.normalise_channel(channel)
102
+ result = _http.search(["channel"], chan, size=lim)
103
+ except (_http.MediathekError, ValueError) as e:
104
+ return _err(e)
105
+ return json.dumps(
106
+ {"channel": chan, **_dom.compact_results(result)}, ensure_ascii=False
107
+ )
108
+
109
+
110
+ @mcp.tool()
111
+ def mediathek_by_topic(topic: str, limit: int = 10) -> str:
112
+ """All episodes of one show/series (topic), newest first. topic: \
113
+ e.g. "heute journal" or "Tagesschau". limit: 1..50 (default 10)."""
114
+ try:
115
+ if not (topic or "").strip():
116
+ return json.dumps(
117
+ {"error": "topic must not be empty"},
118
+ ensure_ascii=False,
119
+ )
120
+ lim = _dom.validate_limit(limit)
121
+ result = _http.search(["topic"], topic.strip(), size=lim)
122
+ except (_http.MediathekError, ValueError) as e:
123
+ return _err(e)
124
+ return json.dumps(
125
+ {"topic": topic.strip(), **_dom.compact_results(result)},
126
+ ensure_ascii=False,
127
+ )
128
+
129
+
130
+ @mcp.tool()
131
+ def mediathek_programme_detail(title: str, channel: str | None = None) -> str:
132
+ """Full detail of one programme: all video URLs (low/standard/HD), \
133
+ subtitle URL and website link. title: exact programme title, e.g. \
134
+ "heute journal vom 8. November 2026 (S2026/E333)". channel: optional, \
135
+ e.g. "ZDF", to disambiguate same-named titles."""
136
+ try:
137
+ if not (title or "").strip():
138
+ return json.dumps(
139
+ {"error": "title must not be empty"},
140
+ ensure_ascii=False,
141
+ )
142
+ result = _http.search(["title"], title.strip(), size=50)
143
+ found = _dom.find_exact_title(
144
+ result.get("results") or [],
145
+ title.strip(),
146
+ _dom.normalise_channel(channel) if channel else None,
147
+ )
148
+ except (_http.MediathekError, ValueError) as e:
149
+ return _err(e)
150
+ if not found:
151
+ return json.dumps(
152
+ {
153
+ "error": f"no programme with exact title {title.strip()!r}"
154
+ + (f" on channel {channel!r}" if channel else ""),
155
+ "hint": "use mediathek_search_programmes first to get an exact title",
156
+ },
157
+ ensure_ascii=False,
158
+ )
159
+ return json.dumps(_dom.compact_programme(found), ensure_ascii=False)
160
+
161
+
162
+ @mcp.tool()
163
+ def mediathek_list_channels() -> str:
164
+ """Channels in the MediathekView film list (curated list — the API \
165
+ has no channel-list endpoint)."""
166
+ return json.dumps(
167
+ {
168
+ "count": len(_dom.CHANNELS),
169
+ "channels": _dom.CHANNELS,
170
+ "note": "curated list of the channels in the film list; "
171
+ "mediathek_by_channel also accepts any other channel name "
172
+ "and passes it to the API unchanged.",
173
+ },
174
+ ensure_ascii=False,
175
+ )
176
+
177
+
178
+ def main() -> None:
179
+ mcp.run()
180
+
181
+
182
+ if __name__ == "__main__":
183
+ sys.exit(main())
File without changes
@@ -0,0 +1 @@
1
+ {"result":{"results":[{"channel":"ZDF","topic":"Wirtschaft","title":"KI: Kollege oder Konkurrent für Junge?","description":"Berufseinsteiger haben es durch KI besonders schwer - aber nicht in allen Jobs. So das Ergebnis einer ifo-Analyse, die Berufe, Altersgruppen und Abhängigkeiten von KI vergleicht.","timestamp":1794214380,"duration":116,"size":39845888,"url_website":"https://www.zdfheute.de/wirtschaft/ki-arbeitsmarkt-ifo-analyse-berufseinsteiger-100.html","url_subtitle":"","url_video":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/10/261019_1400_clip_3_hid/1/261019_1400_clip_3_hid_3360k_p36v17.mp4","url_video_low":"https://rodlzdf-a.akamaihd.net/none/zdf/26/10/261019_1400_clip_3_hid/1/261019_1400_clip_3_hid_808k_p11v17.mp4","url_video_hd":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/10/261019_1400_clip_3_hid/1/261019_1400_clip_3_hid_6660k_p37v17.mp4","filmlisteTimestamp":1794216000,"id":"3F7w+vkNC+SXA3w1mvEj405dbUcbzEo5KbbNEgQJygU="},{"channel":"ZDF","topic":"Politik","title":"Dröge zu Merz’ COP-Absage: \"Ein wirklich schlechtes Signal\"","description":"Grünen-Fraktionschefin Katharina Dröge kritisiert Bundeskanzler Merz für sein Fernbleiben bei der Weltklimakonferenz. Sie fordert mehr Einsatz beim Ausstieg aus fossiler Energie.","timestamp":1794211260,"duration":308,"size":112197632,"url_website":"https://www.zdfheute.de/politik/deutschland/cop-31-klima-konferenz-merz-fossile-energie-droege-100.html","url_subtitle":"","url_video":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261109_clip_17_mom/1/261109_clip_17_mom_3360k_p36v17.mp4","url_video_low":"https://rodlzdf-a.akamaihd.net/none/zdf/26/11/261109_clip_17_mom/1/261109_clip_17_mom_808k_p11v17.mp4","url_video_hd":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261109_clip_17_mom/1/261109_clip_17_mom_6660k_p37v17.mp4","filmlisteTimestamp":1794216000,"id":"6Oq3sTj/QOpcoS0KBkpgolEUgvyJYqTmTk2zeyEgHG0="}],"queryInfo":{"filmlisteTimestamp":1794216000,"searchEngineTime":"4.38","resultCount":2,"totalResults":10000,"totalRelation":"gte","totalEntries":711540}},"err":null}
@@ -0,0 +1 @@
1
+ {"result":{"results":[{"channel":"ZDF","topic":"heute journal","title":"heute journal vom 8. November 2026 (S2026/E333)","description":"Mit den Themen: Nach Schüssen in Krakau - Ermittlungen wegen Terrorismus laufen; Aigner als Steinmeier-Nachfolgerin - CDU-Präsidium berät über Kandidatin; Vor der Cop 31 in Antalya - Alte Hürden für Weltklimakonferenz.","timestamp":1794170700,"duration":1786,"size":631242752,"url_website":"https://www.zdf.de/video/magazine/heute-journal-104/heute-journal-vom-8-november-2026-100","url_subtitle":"https://utstreaming.zdf.de/mtt/zdf/26/11/261108_2145_sendung_hjo/4/heute_journal_081126.xml","url_video":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_sendung_hjo/1/261108_2145_sendung_hjo_3360k_p36v17.mp4","url_video_low":"https://rodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_sendung_hjo/1/261108_2145_sendung_hjo_808k_p11v17.mp4","url_video_hd":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_sendung_hjo/1/261108_2145_sendung_hjo_6660k_p37v17.mp4","filmlisteTimestamp":1794197940,"id":"zsBPTMwoMrTFnHXen7MQ68WdJQ9mzRMlTSTkSHNGYUE="},{"channel":"ZDF","topic":"heute journal","title":"Krakau nach dem Anschlag","description":"Nach der tödlichen Attacke im polnischen Krakau ermittelt die Staatsanwaltschaft wegen Terrorismus. Sechs Menschen und weitere Familienmitglieder des Schützen wurden erschossen.","timestamp":1794170700,"duration":127,"size":44040192,"url_website":"https://www.zdfheute.de/video/heute-journal/krakau-anschlag-ermittlungen-100.html","url_subtitle":"","url_video":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_clip_3_hjo/1/261108_2145_clip_3_hjo_3360k_p36v17.mp4","url_video_low":"https://rodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_clip_3_hjo/1/261108_2145_clip_3_hjo_808k_p11v17.mp4","url_video_hd":"https://nrodlzdf-a.akamaihd.net/none/zdf/26/11/261108_2145_clip_3_hjo/1/261108_2145_clip_3_hjo_6660k_p37v17.mp4","filmlisteTimestamp":1794176340,"id":"qVTXWFi/4GBxbH83HN60NAaUCccYHyzP41wlUwm0BeI="}],"queryInfo":{"filmlisteTimestamp":1794216000,"searchEngineTime":"3.86","resultCount":2,"totalResults":5399,"totalRelation":"eq","totalEntries":711540}},"err":null}
@@ -0,0 +1 @@
1
+ {"result":{"results":[{"channel":"ARTE.DE","topic":"Entdeckung der Welt - Kulinarik","title":"Küchen der Welt - Nordfrankreich: Hering","description":"Typisches Fischgericht aus Dunkerque: Hering mit Kartoffeln / Kulinarische Mitbringsel / Ran an die Töpfe!","timestamp":1794221880,"duration":1806,"size":null,"url_website":"https://www.arte.tv/de/videos/126457-125-A/kuechen-der-welt/","url_subtitle":"","url_video":"https://manifest-arte.akamaized.net/api/manifest/v1/Generate/a1ebc08f-298c-4a3c-aa97-372d34e212a0/de/XQ+KS+CHEV1/126457-125-A.m3u8","url_video_low":"","url_video_hd":"","filmlisteTimestamp":1794197940,"id":"y0Mv6ZJx/cIH1vteFS+aBGEIHJY7m8ah9WFNDae/es4="},{"channel":"BR","topic":"aktiv und gesund","title":"Lipödem, Teebaumöl, Ernährungstrends im Wandel","description":"Lipödem Das Lipödem ist eine Krankheit, von der die meisten Betroffenen nichts wissen. Rund 3,8 Millionen Menschen sind in Deutschland betroffen, hauptsächlich Frauen. Durch eine Fettverteilungsstörung kommt es zu einer Fettvermehrung vor allem an Beinen, Hüfte, Gesäß und in einigen Fällen auch an den Armen. Die Folgen sind heftige Schmerzen und Spannungsgefühle....","timestamp":1794221400,"duration":1689,"size":636485632,"url_website":"https://www.ardmediathek.de/video/Y3JpZDovL2JyLmRlL2Jyb2FkY2FzdC9GMjAyNFdPMDE1MTE5QTA","url_subtitle":"https://api.ardmediathek.de/player-service/subtitle/ebutt/urn:ard:subtitle:f3e8a8b5d02a1294","url_video":"https://cdn-storage.br.de/b7/2025-04/30/3bdb8ba6-25c7-11f0-a101-02420a000526_X.mp4","url_video_low":"https://cdn-storage.br.de/b7/2025-04/30/3bdb8ba6-25c7-11f0-a101-02420a000526_E.mp4","url_video_hd":"https://cdn-storage.br.de/b7/2025-04/30/3bdb8ba6-25c7-11f0-a101-02420a000526_HD.mp4","filmlisteTimestamp":1790717100,"id":"CcQMhDp4LUoicNkAMpne4HrMHnexihB3WI6jaIDvUuU="}],"queryInfo":{"filmlisteTimestamp":1794216000,"searchEngineTime":"4.06","resultCount":2,"totalResults":10000,"totalRelation":"gte","totalEntries":711540}},"err":null}
@@ -0,0 +1 @@
1
+ {"result":{"results":[{"channel":"tagesschau24","topic":"tagesschau in 100 Sekunden","title":"tagesschau in 100 Sekunden","description":"tagesschau in 100 Sekunden","timestamp":1794195780,"duration":170,"size":40894464,"url_website":"https://www.ardmediathek.de/video/Y3JpZDovL3RhZ2Vzc2NoYXUuZGUvN2FjNWY5ZmItYTQxNi00OTcwLWJiNmMtYTJmMjJhZDA4M2Nl","url_subtitle":"","url_video":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxl.h264.mp4","url_video_low":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webm.h264.mp4","url_video_hd":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0445-5500.webxxl.h264.mp4","filmlisteTimestamp":1794212340,"id":"+sU90jI0m7KCrRqsj5aYEh9Owg7tr+3K9IRl39v+A8o="},{"channel":"tagesschau24","topic":"tagesschau in 100 Sekunden","title":"tagesschau in 100 Sekunden","description":"tagesschau in 100 Sekunden","timestamp":1794189480,"duration":160,"size":41943040,"url_website":"https://www.ardmediathek.de/video/Y3JpZDovL3RhZ2Vzc2NoYXUuZGUvZTUzZDE4YjUtZmYxMC00ZWFkLTljYmMtYTQzOWY4NzQwOWU3","url_subtitle":"","url_video":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0257-3000.webxl.h264.mp4","url_video_low":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0257-3000.webm.h264.mp4","url_video_hd":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0257-3000.webxxl.h264.mp4","filmlisteTimestamp":1794212340,"id":"KeXQDG431jwNB7OwGCb2GnFKFfQpNH31Mstz1Xp2ccc="},{"channel":"tagesschau24","topic":"tagesschau in 100 Sekunden","title":"tagesschau in 100 Sekunden","description":"tagesschau in 100 Sekunden","timestamp":1794183780,"duration":150,"size":34603008,"url_website":"https://www.ardmediathek.de/video/Y3JpZDovL3RhZ2Vzc2NoYXUuZGUvODE3MzAyY2QtY2Y4Ni00NmNkLTllZWItYzNiZWI4MmI4YTEy","url_subtitle":"","url_video":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0127-3500.webxl.h264.mp4","url_video_low":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0127-3500.webm.h264.mp4","url_video_hd":"https://tagesschau-progressive.ard-mcdn.de/video/2026/1109/TV-20261109-0127-3500.webxxl.h264.mp4","filmlisteTimestamp":1794212340,"id":"8pFbcaTz6MbBjO/mlb4py3cZW+48RF5h0aYdkIA1YMs="}],"queryInfo":{"filmlisteTimestamp":1794216000,"searchEngineTime":"3.69","resultCount":3,"totalResults":8823,"totalRelation":"eq","totalEntries":711540}},"err":null}
@@ -0,0 +1,200 @@
1
+ """Offline tests — parse real captured MediathekViewWeb responses, no network."""
2
+ import json
3
+ import pathlib
4
+ import sys
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
9
+
10
+ from mediathek_mcp import domain as dom
11
+ from mediathek_mcp import server as srv
12
+
13
+ FIX = pathlib.Path(__file__).resolve().parent / "fixtures"
14
+
15
+
16
+ def _load(name):
17
+ return json.loads((FIX / name).read_text(encoding="utf-8"))
18
+
19
+
20
+ def _result(name):
21
+ return _load(name)["result"]
22
+
23
+
24
+ class TestCompact(unittest.TestCase):
25
+ def test_search_fixture_shape(self):
26
+ out = dom.compact_results(_result("search-tagesschau.json"))
27
+ self.assertEqual(out["total_results"], 8823)
28
+ self.assertEqual(out["count"], 3)
29
+ self.assertEqual(len(out["programmes"]), 3)
30
+
31
+ def test_compact_programme_fields(self):
32
+ p = dom.compact_programme(_result("search-tagesschau.json")["results"][0])
33
+ self.assertEqual(p["channel"], "tagesschau24")
34
+ self.assertEqual(p["topic"], "tagesschau in 100 Sekunden")
35
+ self.assertEqual(p["title"], "tagesschau in 100 Sekunden")
36
+ self.assertEqual(p["duration_seconds"], 170)
37
+ self.assertEqual(p["duration"], "2:50")
38
+ self.assertTrue(p["broadcast"].startswith("2026-11-09T"))
39
+ self.assertTrue(p["video_url"].endswith(".mp4"))
40
+ self.assertTrue(p["video_url_hd"].endswith(".mp4"))
41
+ self.assertTrue(p["website_url"].startswith("https://www.ardmediathek.de/"))
42
+ self.assertEqual(p["size_bytes"], 40894464)
43
+
44
+ def test_topic_fixture_duration(self):
45
+ p = dom.compact_programme(_result("by-topic-heute-journal.json")["results"][0])
46
+ self.assertEqual(p["channel"], "ZDF")
47
+ self.assertEqual(p["topic"], "heute journal")
48
+ self.assertEqual(p["duration_seconds"], 1786)
49
+ self.assertEqual(p["duration"], "29:46")
50
+
51
+ def test_channel_fixture_all_zdf(self):
52
+ out = dom.compact_results(_result("by-channel-zdf.json"))
53
+ self.assertTrue(all(p["channel"] == "ZDF" for p in out["programmes"]))
54
+ self.assertEqual(out["total_results"], 10000)
55
+
56
+ def test_latest_fixture(self):
57
+ out = dom.compact_results(_result("latest.json"))
58
+ self.assertEqual(out["count"], 2)
59
+ self.assertTrue(all(p["broadcast"] for p in out["programmes"]))
60
+
61
+
62
+ class TestHelpers(unittest.TestCase):
63
+ def test_duration_hms(self):
64
+ self.assertEqual(dom._duration_hms(170), "2:50")
65
+ self.assertEqual(dom._duration_hms(3661), "1:01:01")
66
+ self.assertEqual(dom._duration_hms(45), "0:45")
67
+ self.assertIsNone(dom._duration_hms(None))
68
+
69
+ def test_validate_limit(self):
70
+ self.assertEqual(dom.validate_limit(10), 10)
71
+ with self.assertRaises(ValueError):
72
+ dom.validate_limit(0)
73
+ with self.assertRaises(ValueError):
74
+ dom.validate_limit(51)
75
+
76
+ def test_normalise_channel(self):
77
+ self.assertEqual(dom.normalise_channel("zdf"), "ZDF")
78
+ self.assertEqual(dom.normalise_channel(" ARTE.de "), "ARTE.DE")
79
+ self.assertEqual(dom.normalise_channel("phoenix"), "PHOENIX")
80
+ # Unknown names pass through unchanged.
81
+ self.assertEqual(dom.normalise_channel("SomeChannel"), "SomeChannel")
82
+ with self.assertRaises(ValueError):
83
+ dom.normalise_channel(" ")
84
+
85
+ def test_find_exact_title(self):
86
+ results = _result("by-topic-heute-journal.json")["results"]
87
+ title = "heute journal vom 8. November 2026 (S2026/E333)"
88
+ found = dom.find_exact_title(results, title, None)
89
+ self.assertIsNotNone(found)
90
+ self.assertEqual(found["channel"], "ZDF")
91
+ # Case-insensitive match.
92
+ self.assertIsNotNone(dom.find_exact_title(results, title.upper(), None))
93
+ # Channel restriction.
94
+ self.assertIsNotNone(dom.find_exact_title(results, title, "zdf"))
95
+ self.assertIsNone(dom.find_exact_title(results, title, "ARD"))
96
+ # No match.
97
+ self.assertIsNone(dom.find_exact_title(results, "does not exist", None))
98
+
99
+
100
+ class TestServerToolsOffline(unittest.TestCase):
101
+ """Server tools with the HTTP layer mocked to return fixture data."""
102
+
103
+ def test_search_programmes(self):
104
+ with mock.patch(
105
+ "mediathek_mcp.http.search",
106
+ return_value=_result("search-tagesschau.json"),
107
+ ):
108
+ out = json.loads(srv.mediathek_search_programmes("Tagesschau"))
109
+ self.assertNotIn("error", out)
110
+ self.assertEqual(out["query"], "Tagesschau")
111
+ self.assertEqual(out["count"], 3)
112
+ self.assertEqual(out["programmes"][0]["channel"], "tagesschau24")
113
+
114
+ def test_search_programmes_empty_query(self):
115
+ out = json.loads(srv.mediathek_search_programmes(" "))
116
+ self.assertIn("error", out)
117
+
118
+ def test_search_programmes_bad_limit(self):
119
+ out = json.loads(srv.mediathek_search_programmes("Tagesschau", limit=99))
120
+ self.assertIn("error", out)
121
+
122
+ def test_search_programmes_with_channel_filters(self):
123
+ # Fixture has 3 tagesschau24 results; filtering to ZDF yields none.
124
+ with mock.patch(
125
+ "mediathek_mcp.http.search",
126
+ return_value=_result("search-tagesschau.json"),
127
+ ):
128
+ out = json.loads(
129
+ srv.mediathek_search_programmes("Tagesschau", channel="ZDF")
130
+ )
131
+ self.assertNotIn("error", out)
132
+ self.assertEqual(out["channel"], "ZDF")
133
+ self.assertEqual(out["count"], 0)
134
+ self.assertEqual(out["total_results"], 0)
135
+
136
+ def test_latest_broadcasts(self):
137
+ with mock.patch(
138
+ "mediathek_mcp.http.search",
139
+ return_value=_result("latest.json"),
140
+ ):
141
+ out = json.loads(srv.mediathek_latest_broadcasts(limit=2))
142
+ self.assertNotIn("error", out)
143
+ self.assertEqual(out["count"], 2)
144
+
145
+ def test_by_channel(self):
146
+ with mock.patch(
147
+ "mediathek_mcp.http.search",
148
+ return_value=_result("by-channel-zdf.json"),
149
+ ) as m:
150
+ out = json.loads(srv.mediathek_by_channel("zdf", limit=2))
151
+ self.assertNotIn("error", out)
152
+ self.assertEqual(out["channel"], "ZDF")
153
+ # Normalised channel is what reaches the API.
154
+ self.assertEqual(m.call_args.args[1], "ZDF")
155
+
156
+ def test_by_topic(self):
157
+ with mock.patch(
158
+ "mediathek_mcp.http.search",
159
+ return_value=_result("by-topic-heute-journal.json"),
160
+ ):
161
+ out = json.loads(srv.mediathek_by_topic("heute journal"))
162
+ self.assertNotIn("error", out)
163
+ self.assertEqual(out["topic"], "heute journal")
164
+ self.assertEqual(out["count"], 2)
165
+
166
+ def test_programme_detail_found(self):
167
+ with mock.patch(
168
+ "mediathek_mcp.http.search",
169
+ return_value=_result("by-topic-heute-journal.json"),
170
+ ):
171
+ out = json.loads(
172
+ srv.mediathek_programme_detail(
173
+ "heute journal vom 8. November 2026 (S2026/E333)"
174
+ )
175
+ )
176
+ self.assertNotIn("error", out)
177
+ self.assertEqual(out["channel"], "ZDF")
178
+ self.assertTrue(out["video_url"].endswith(".mp4"))
179
+
180
+ def test_programme_detail_missing(self):
181
+ with mock.patch(
182
+ "mediathek_mcp.http.search",
183
+ return_value=_result("by-topic-heute-journal.json"),
184
+ ):
185
+ out = json.loads(
186
+ srv.mediathek_programme_detail("no such programme title")
187
+ )
188
+ self.assertIn("error", out)
189
+ self.assertIn("hint", out)
190
+
191
+ def test_list_channels(self):
192
+ out = json.loads(srv.mediathek_list_channels())
193
+ self.assertGreaterEqual(out["count"], 10)
194
+ channels = [c["channel"] for c in out["channels"]]
195
+ for want in ("ARD", "ZDF", "3sat", "ARTE.DE", "PHOENIX", "tagesschau24"):
196
+ self.assertIn(want, channels)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ unittest.main()
@@ -0,0 +1,70 @@
1
+ """Live tests — hit the real mediathekviewweb.de API. Cautious: few requests.
2
+
3
+ Run: pytest tests/test_live.py -v
4
+ Skip: pytest -m "not live" (or set MEDIATHEK_SKIP_LIVE=1)
5
+ """
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import sys
10
+ import unittest
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
15
+
16
+ from mediathek_mcp import server as srv
17
+
18
+ pytestmark = pytest.mark.live
19
+ SKIP = os.environ.get("MEDIATHEK_SKIP_LIVE") == "1"
20
+
21
+
22
+ @pytest.mark.skipif(SKIP, reason="MEDIATHEK_SKIP_LIVE=1")
23
+ class TestLive(unittest.TestCase):
24
+ def test_search_live(self):
25
+ out = json.loads(srv.mediathek_search_programmes("Tagesschau", limit=3))
26
+ self.assertNotIn("error", out)
27
+ self.assertGreaterEqual(out["count"], 1)
28
+ self.assertTrue(all(p["title"] and p["channel"] for p in out["programmes"]))
29
+ self.assertTrue(all(p["video_url"] for p in out["programmes"]))
30
+ print(f"\nLIVE search 'Tagesschau': {out['count']} results, "
31
+ f"total {out['total_results']}")
32
+
33
+ def test_latest_live(self):
34
+ out = json.loads(srv.mediathek_latest_broadcasts(limit=3))
35
+ self.assertNotIn("error", out)
36
+ self.assertGreaterEqual(out["count"], 1)
37
+ self.assertTrue(all(p["broadcast"] for p in out["programmes"]))
38
+ print(f"\nLIVE latest: {out['programmes'][0]['channel']} — "
39
+ f"{out['programmes'][0]['title']}")
40
+
41
+ def test_by_channel_live(self):
42
+ out = json.loads(srv.mediathek_by_channel("ZDF", limit=3))
43
+ self.assertNotIn("error", out)
44
+ self.assertGreaterEqual(out["count"], 1)
45
+ self.assertTrue(all(p["channel"] == "ZDF" for p in out["programmes"]))
46
+ print(f"\nLIVE ZDF newest: {out['programmes'][0]['title']}")
47
+
48
+ def test_by_topic_live(self):
49
+ out = json.loads(srv.mediathek_by_topic("heute journal", limit=3))
50
+ self.assertNotIn("error", out)
51
+ self.assertGreaterEqual(out["count"], 1)
52
+ self.assertTrue(
53
+ all(p["topic"] == "heute journal" for p in out["programmes"])
54
+ )
55
+
56
+ def test_programme_detail_live(self):
57
+ found = json.loads(srv.mediathek_search_programmes("Tagesschau", limit=1))
58
+ title = found["programmes"][0]["title"]
59
+ out = json.loads(srv.mediathek_programme_detail(title))
60
+ self.assertNotIn("error", out)
61
+ self.assertEqual(out["title"], title)
62
+ self.assertTrue(out["video_url"])
63
+ self.assertTrue(out["video_url_hd"])
64
+ print(f"\nLIVE detail: {title} — {out['duration']} "
65
+ f"({out['channel']})")
66
+
67
+ def test_list_channels_live(self):
68
+ out = json.loads(srv.mediathek_list_channels())
69
+ self.assertNotIn("error", out)
70
+ self.assertGreaterEqual(out["count"], 10)