# Noridoc: mediathek_py

Path: @/src/mediathek_py

### Overview

This is the core library package for mediathek-py. It provides a synchronous Python client for the MediathekViewWeb API, Pydantic models for request/response serialization, a custom exception hierarchy, series/episode domain logic, and a Click-based CLI with Rich output. The package exposes two main interfaces: a programmatic fluent-builder API and a prefix-syntax string search API, plus a series collection layer for batch episode downloads.

### How it fits into the larger codebase

This package is the entire production source for mediathek-py. The `__init__.py` re-exports all public symbols from `client`, `models`, `exceptions`, and `series`, making `mediathek_py` the single import target for consumers. The CLI entry point (`mediathek` command) is registered in `pyproject.toml` pointing to `cli:cli`. The test suite at `@/tests` imports directly from this package and mocks the HTTP layer using `respx`.

### Core Implementation

The data flow through the package follows this path:

```
User input (builder API, prefix string, or series query)
        |
        v
  SearchBuilder  --->  MediathekRequest (Pydantic)
        |                     |
        v                     v
  Mediathek._send_query()  model_dump(by_alias=True, exclude_none=True)
        |                     |
        v                     v
  httpx POST to /api/query  (Content-Type: text/plain, despite JSON body)
        |
        v
  Response JSON  --->  QueryResult (Pydantic, with field_validators)

For series/batch operations, an additional domain layer sits above:

  collect_series(client, query, config=None)
        |
        v
  build_from_string(query) -> SearchBuilder (sorted by timestamp asc)
        |
        v
  Paginated API calls
        |
        v
  extract_episode_info(item, config) on each Item
        |
        +--> config is None: delegate to parse_episode_info(title)
        |
        +--> config provided: custom regex per dimension,
        |    fallback to parse_episode_info for unconfigured dimensions
        |
        v
  Deduplicated, sorted list[SeriesEpisode]
```

**`client.py`** contains `Mediathek` and `SearchBuilder`. `Mediathek` wraps an `httpx.Client` and handles HTTP communication, the response envelope (`err`/`result`), and file downloads via streaming. `SearchBuilder` is a fluent builder that accumulates query parameters and calls `Mediathek._send_query()` on `.execute()`. The `build_from_string()` method on `Mediathek` parses a prefix-syntax string (e.g., `"!ard #tagesschau >10"`) into a `SearchBuilder` by tokenizing on whitespace and dispatching on the first character (`!`, `#`, `+`, `*`, `>`, `<`). Duration values in the builder API are accepted in minutes and converted to seconds (multiplied by 60) before being placed into the request.

**`models.py`** defines three enums (`QueryField`, `SortField`, `SortOrder`), request models (`Query`, `MediathekRequest`), and response models (`Item`, `QueryInfo`, `QueryResult`). All models use `ConfigDict(populate_by_name=True)` to support both snake_case attribute access and camelCase JSON aliases. `MediathekRequest` serialization uses `by_alias=True, exclude_none=True` so unset optional fields are omitted from the JSON body entirely. The `Item` model uses `field_validator(mode="before")` to handle API quirks: empty strings become `None` for optional fields, `duration` can be an empty string (livestreams), and timestamps accept both int and string representations.

**`exceptions.py`** defines `MediathekError` (base), `ApiError` (carries a `messages: list[str]` from the API `err` array), `DownloadError` (carries `status_code: int` and `url: str`, with a `reason` property that maps HTTP status codes to human-readable messages), and `EmptyResponseError` (when `result` is null without an error). `DownloadError` inherits from `MediathekError`, so existing `except MediathekError` catches remain backward-compatible. The `reason` property is the single source of truth for HTTP-status-to-message mapping (e.g., 403 -> expired/geo-restricted, 404 -> no longer available, 410 -> permanently removed, 429 -> rate limited, 5xx -> server error), falling back to `"HTTP {code}"` for unmapped codes.

**`series.py`** contains the domain logic for series/batch operations, with no HTTP logic of its own. Episode extraction has two layers: a default path and a configurable path. The default path, `parse_episode_info(title)`, uses two regex patterns to extract season and episode numbers from title strings: `(SXX/EXX)` format is tried first, then `Folge N` as a fallback (which defaults season to 1). The configurable path uses `ExtractionConfig` (a frozen dataclass) to specify per-dimension regex patterns and which `Item` field to extract from. `ExtractionConfig` has four fields: `season_pattern`, `season_field`, `episode_pattern`, `episode_field`, where each `*_field` defaults to `"title"` and each `*_pattern` defaults to `None`. When a pattern is `None` for a dimension, that dimension falls back to the default title-based extraction. The virtual field `"date"` is supported, which converts the item's timestamp to `"YYYY-MM-DD"` format via `_get_field_value()` before pattern matching. `ExtractionConfig` enforces its own invariants at construction time via `__post_init__()`: field names are validated against the module-level `_VALID_FIELDS` frozenset (`{"title", "topic", "description", "channel", "date"}`), and patterns are compiled to verify valid regex syntax and at least one capture group. This fail-fast validation means configuration errors are caught at the boundary (when `ExtractionConfig` is constructed), before any API calls are made. Downstream, `_extract_number()` assumes patterns are already validated and simply applies the regex, returning `None` on non-match. `extract_episode_info(item, config)` orchestrates this: with `config=None` it delegates to `parse_episode_info`; with a config, it runs custom extraction for dimensions that have patterns, then falls back to default parsing only for dimensions without custom patterns. Both season and episode must resolve to non-None for an `EpisodeInfo` to be returned. `collect_series(client, query, *, config=None)` paginates through search results by delegating query parsing to `client.build_from_string(query)` (the same prefix-syntax parser used by all CLI commands), sorting by timestamp ascending, and calling `extract_episode_info(item, config)` for each item. This means the query parameter supports the full prefix syntax (`!channel #topic +title *description >min_dur <max_dur`), not just topic names. Pagination is capped at `_MAX_PAGES` (200) requests to prevent unbounded API calls if the API returns inconsistent `total_results` values. It deduplicates episodes by `(season, episode)` tuple using a `seen` set (keeping the earliest-timestamp occurrence, since results arrive in timestamp ascending order), skips items that cannot be parsed, and returns the full list sorted by `(season, episode)`. The `SeriesEpisode` dataclass wraps an `Item` and an `EpisodeInfo` and provides a `filename()` method that generates `sXXeXX.mp4` format strings.

**`cli.py`** provides four Click commands (`search`, `info`, `download`, `batch`) under a `@click.group()`. It uses Rich for output: `Table` for search results and batch preview, `Panel` for detailed info, and `Progress` with download-specific columns for file downloads. Two shared helpers factor out common behavior: `_sanitize_path_component(name)` replaces any character that is not alphanumeric, space, dash, or underscore with an underscore -- used by both `download` (for auto-generated filenames) and `batch` (for topic subdirectory names), ensuring consistent sanitization across all file-system paths; and `_download_progress()` is a factory that returns a preconfigured Rich `Progress` bar, reused by both `download` and `batch`. The `_select_video_url` helper implements a quality fallback chain (e.g., hd -> medium -> low).

The `download` command searches for a single video, selects a URL based on the quality preference, and downloads it. Error handling catches `DownloadError` separately before `MediathekError` so it can display the human-readable `reason` property (e.g., "expired/geo-restricted" for 403, "no longer available" for 404) rather than the raw exception message.

The `batch` command uses `collect_series()` from `series.py` to gather all episodes for a topic, displays a preview table, and optionally filters by season (`--season`/`-s`) and/or episode number (`--episode`/`-e`). The season and episode filters compose sequentially: season filters first, then episode filters the remaining list, so `--season 1 --episode 2` selects only S01E02. Both filters apply to all output modes. The batch command also accepts custom extraction flags (`--use-date`, `--episode-pattern`, `--episode-field`, `--season-pattern`, `--season-field`) that build an `ExtractionConfig` and pass it to `collect_series()`. `--use-date` is syntactic sugar that sets `season_field="date"` and `season_pattern=r"^(\d{4})"`. Validation enforces mutual exclusivity (`--use-date` cannot be combined with `--season-pattern` or `--season-field`) and dependency (`--*-field` requires the corresponding `--*-pattern`). Without any extraction flags, `config=None` is passed to `collect_series()`, preserving the default title-based extraction. The command then branches into one of two output modes based on the `--list-url` flag. With `--list-url`, the preview table is printed to stderr (via `Console(stderr=True).print(table)`) to keep stdout clean for piping, then it prints one download URL per line to stdout using `click.echo()`, silently skipping episodes with no available URL at the selected quality, and returns early without prompting or downloading. Without `--list-url`, it follows the normal download flow: prompts for confirmation (skippable with `--yes`), creates a topic-named subdirectory, and downloads episodes sequentially with skip-if-exists logic. Both output modes respect the `--quality` option.

Error handling in the batch download loop collects failures as `(filename, reason)` tuples rather than printing them immediately. Failed progress bars are hidden via `progress.update(task_id, visible=False)`. On completion, if there are failures, a Rich `Table` titled "Failed downloads" is displayed listing each file with its human-readable reason. The summary line below the table is color-coded: green when all succeed, yellow for partial success (some downloaded), red when all fail. Both download and batch commands rely on `DownloadError.reason` as the single source of truth for HTTP-status-to-message translation (e.g., 403 -> "expired/geo-restricted", 404 -> "no longer available", 410 -> "permanently removed", 429 -> "rate limited", 5xx -> "server error").

### Things to Know

- The API requires `Content-Type: text/plain` even though the body is JSON. This is a documented quirk of the MediathekViewWeb API and is hardcoded in `_send_query()`.
- `model_dump(by_alias=True, exclude_none=True)` on `MediathekRequest` is the serialization invariant -- the API rejects requests containing null-valued optional fields, so they must be omitted entirely.
- The prefix-syntax parser in `build_from_string()` replaces commas with spaces within prefixed tokens (e.g., `#sturm,der,liebe` becomes query `"sturm der liebe"`). Unprefixed tokens are joined with spaces into a single query targeting `[topic, title]` (or all four fields with `search_everywhere=True`).
- The `Item.size` field is `int | None` because the real API returns `null` for some items -- this was discovered during smoke testing and was not in the original plan.
- The download method uses `httpx.Client.stream()` with `iter_bytes()` and supports an optional progress callback that receives `(downloaded_bytes, total_bytes_or_none)`. On HTTP errors, it raises `DownloadError(status_code, url)` rather than a generic `MediathekError`, providing structured error info that the CLI uses for human-readable messages.
- `collect_series()` uses `QueryResult.query_info.total_results` to decide when to stop paginating. It compares the accumulated offset against this value. As a safety net, pagination is also hard-capped at `_MAX_PAGES` (200) iterations to guard against inconsistent API totals.
- `parse_episode_info()` returns `None` for titles that match neither the `(SXX/EXX)` nor `Folge N` patterns (e.g., trailers, specials). These items are silently dropped by `collect_series()`.
- `ExtractionConfig.__post_init__()` is the single validation boundary for extraction configuration. It rejects invalid field names and malformed regex patterns at construction time, so downstream code (`_extract_number()`, `extract_episode_info()`) can assume all config values are valid. The allowlist of valid fields is the `_VALID_FIELDS` frozenset at module level in `series.py`.
- In `extract_episode_info`, fallback to default title parsing is only attempted for dimensions where the config does not specify a pattern. If a custom pattern is provided but does not match, that dimension stays `None` -- there is no fallback to default parsing for failed custom patterns. This means a non-matching custom pattern produces `None` overall, even if the title contains valid `(SXX/EXX)` data for that dimension.
- The `batch` CLI command passes the raw query string directly to `collect_series()`, which delegates to `build_from_string()` for parsing. This means `batch` supports the same full prefix syntax as `search` (e.g., `"!WDR #Feuer,&,Flamme >30"`), with commas replacing spaces within prefixed tokens.
- In the batch download loop, the progress callback closure captures `task_id` via a default argument (`_tid=task_id`) to avoid the late-binding closure problem when iterating through episodes.

Created and maintained by Nori.
