# Руководство по Java movie API

> Создайте консольный гид по сериям на Java, который выводит каждую серию сезона с номером, датой выхода и длительностью. Используются JDK HttpClient и Jackson, JSON превращается в records, ответ 429 повторяется после паузы из Retry-After, а ответы кэшируются в файлах. Нужны Java 17 или новее и Maven 3.9.

Вы напишете `EpisodeGuide`, консольную программу. Запустите ее с id сериала и номером сезона, и она выведет каждую серию с номером, датой выхода, длительностью и названием. Используются `HttpClient` из JDK, Jackson 2.x, Java 17 или новее и Maven 3.9. Заложите 20 минут.

## Шаг 1: получите ключ API

Для ключа нужен тариф с предоплатой, поэтому начните со страницы [цены](https://amodb.dev/ru/pricing/). Все тарифы отдают одни и те же данные, различается только число запросов в минуту. Создайте ключ в личном кабинете и экспортируйте его:

```bash
export AMODB_KEY="YOUR_API_KEY"
```

Программа читает `System.getenv("AMODB_KEY")` и отказывается запускаться без него.

## Шаг 2: создайте проект

Единственная зависимость: Jackson. Создайте папку, минимальный `pom.xml` и каталог с исходниками:

```bash
mkdir episode-guide && cd episode-guide
mkdir -p src/main/java
cat > pom.xml <<'EOF'
<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>episode-guide</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.18.2</version>
    </dependency>
  </dependencies>
</project>
EOF
```

Подойдет и более новый релиз `jackson-databind` ветки 2.x.

## Шаг 3: отправьте первый запрос

Эндпоинт сезона: `GET /3/tv/{tv_id}/season/{season_number}`, а Breaking Bad имеет id сериала `1396`. Этот класс выводит код статуса. Сохраните его как `src/main/java/First.java`, чтобы попробовать отдельно. Команда запуска из шага 7 подойдет и для него, если указать `-Dexec.mainClass=First`.

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class First {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.amodb.dev/3/tv/1396/season/1"))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + System.getenv("AMODB_KEY"))
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode());
    }
}
```

## Шаг 4: сопоставьте JSON с records

Ответ по сезону содержит `name` и массив `episodes`. У каждой серии есть `episode_number`, `name`, `air_date` и `runtime`, причем последние два могут быть `null` для серии, которая еще не вышла. Для числа, которого может не быть, используйте `Integer`. Полный список полей приведен на странице [сезоны и серии](https://amodb.dev/ru/tv-seasons-episodes/).

```java
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
        @JsonProperty("episode_number") int number,
        String name,
        @JsonProperty("air_date") String airDate,
        Integer runtime) {}
```

## Шаг 5: обработайте ошибки и лимиты запросов

Прочитайте статус, прежде чем трогать тело ответа.

| Статус                      | Что делает программа                                                                                         |
| --------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 200                         | Возвращает тело для Jackson.                                                                                 |
| 401                         | Выбрасывает `IOException` с упоминанием `AMODB_KEY`. Неверный ключ ломает каждый вызов, поэтому повтора нет. |
| 404                         | Выбрасывает исключение с `status_message`, например когда сезона не существует.                              |
| 429                         | Читает `Retry-After`, ждет от 1 до 30 секунд и отправляет запрос снова, максимум 3 попытки.                  |
| Таймаут или сбой соединения | Пробрасывается заново как `network error` с причиной внутри.                                                 |

Тело ошибки имеет формат TMDB, поэтому показывать нужно текст из `status_message`. Страница [ошибки](https://amodb.dev/ru/errors/) перечисляет все коды, а [лимиты запросов](https://amodb.dev/ru/rate-limits/) объясняют окно в 60 секунд.

## Шаг 6: кэшируйте ответы в файлах

Edge-кэш хранит детали 6 часов, поэтому список серий редко меняется между запусками. Программа записывает каждое тело ответа в файл во временном каталоге, называет его по пути запроса и читает обратно, пока файл моложе TTL. Попадание в кэш ничего не отправляет и не тратит ваши запросы в минуту.

## Шаг 7: полная программа

Сохраните это как `src/main/java/EpisodeGuide.java`.

```java
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;

public class EpisodeGuide {
    private static final String BASE = "https://api.amodb.dev/3";
    private static final String KEY = System.getenv("AMODB_KEY");
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private static final Path CACHE_DIR = Path.of(System.getProperty("java.io.tmpdir"), "amodb-cache");

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Episode(
            @JsonProperty("episode_number") int number,
            String name,
            @JsonProperty("air_date") String airDate,
            Integer runtime) {}

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Season(String name, List<Episode> episodes) {}

    static String get(String path) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
                .timeout(Duration.ofSeconds(10))
                .header("Authorization", "Bearer " + KEY)
                .header("Accept", "application/json")
                .GET()
                .build();
        for (int attempt = 1; ; attempt++) {
            HttpResponse<String> response;
            try {
                response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (IOException e) {
                throw new IOException("network error: " + e, e);
            }
            int status = response.statusCode();
            if (status == 200) {
                return response.body();
            }
            if (status == 401) {
                throw new IOException("401: the key is invalid or suspended, check AMODB_KEY");
            }
            if (status == 429 && attempt < 3) {
                long seconds = response.headers().firstValueAsLong("Retry-After").orElse(1);
                Thread.sleep(Math.min(Math.max(seconds, 1), 30) * 1000);
                continue;
            }
            throw new IOException("amodb returned " + status + ": " + message(response.body()));
        }
    }

    static String message(String body) {
        try {
            return MAPPER.readTree(body).path("status_message").asText("no message");
        } catch (IOException e) {
            return "no message";
        }
    }

    static String cachedGet(String path, Duration ttl) throws IOException, InterruptedException {
        Files.createDirectories(CACHE_DIR);
        Path file = CACHE_DIR.resolve(path.replaceAll("[^A-Za-z0-9]", "_") + ".json");
        if (Files.exists(file)) {
            Instant modified = Files.getLastModifiedTime(file).toInstant();
            if (modified.plus(ttl).isAfter(Instant.now())) {
                return Files.readString(file);
            }
        }
        String body = get(path);
        Files.writeString(file, body);
        return body;
    }

    public static void main(String[] args) {
        if (args.length != 2) {
            System.err.println("Usage: EpisodeGuide <tv_id> <season_number>");
            System.exit(2);
        }
        if (KEY == null || KEY.isBlank()) {
            System.err.println("Set AMODB_KEY first.");
            System.exit(2);
        }
        String path;
        try {
            path = "/tv/" + Integer.parseInt(args[0]) + "/season/" + Integer.parseInt(args[1]) + "?language=en-US";
        } catch (NumberFormatException e) {
            System.err.println("The series id and season number must be integers.");
            System.exit(2);
            return;
        }
        try {
            Season season = MAPPER.readValue(cachedGet(path, Duration.ofHours(6)), Season.class);
            System.out.println(season.name() + ", " + season.episodes().size() + " episodes");
            for (Episode ep : season.episodes()) {
                System.out.printf("%2d  %-10s  %-7s  %s%n",
                        ep.number(),
                        Objects.requireNonNullElse(ep.airDate(), "TBA"),
                        ep.runtime() == null ? "?" : ep.runtime() + " min",
                        ep.name());
            }
        } catch (IOException e) {
            System.err.println("error: " + e.getMessage());
            System.exit(1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.exit(1);
        }
    }
}
```

Скомпилируйте и запустите для сезона 1 сериала 1396:

```bash
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"
```

Вывод состоит из строки заголовка с названием сезона и числом серий, затем по одной строке на серию. Серия без даты выхода показывает `TBA`, а без длительности показывает `?`. Запустите команду дважды: второй запуск читает файл из временного каталога и не отправляет запрос, пока не пройдет 6 часов. Удалите папку `amodb-cache`, чтобы получить свежие данные.

Храните `HttpClient` и `ObjectMapper` в статических полях. Оба потокобезопасны и дороги в создании, поэтому один экземпляр на процесс считается нормальной практикой, в том числе в веб-сервисе.

## Что дальше

- Пройдите циклом от сезона 1 до `number_of_seasons` из [данных о сериале](https://amodb.dev/ru/tv-series/) и выведите весь сериал.
- Покажите следующую серию идущего сериала через `next_episode_to_air`.
- Добавьте третий аргумент для параметра `language`, а список поддерживаемых тегов смотрите на странице [языки](https://amodb.dev/ru/languages/).
- Сравните с тем же подходом на [Go](https://amodb.dev/ru/go-movie-api/) или начните с обзора [API гида по сериям](https://amodb.dev/ru/episode-guide-api/).

## Вопросы и ответы

### Какая версия Java и какие зависимости нужны?

Java 17 или новее. Код использует records, клиент java.net.http из JDK и Files.readString. Единственная зависимость: Jackson databind 2.x, он сам подтягивает jackson-core и jackson-annotations.

### Зачем использовать клиент из JDK, а не стороннюю HTTP-библиотеку?

Он закрывает все потребности этой программы: таймауты на запрос, заголовки и коды статуса, без лишнего jar. Создайте один HttpClient и используйте его весь процесс, потому что экземпляр владеет пулом соединений.

### Как программа обрабатывает ответ 429?

Она читает Retry-After через firstValueAsLong, ограничивает значение диапазоном от 1 до 30 секунд, ждет и отправляет тот же запрос снова, максимум 3 попытки. Ответ 401 выбрасывается сразу, без повтора.

### Можно ли использовать код для фильмов?

Да. Замените путь на /movie/{id} и объявите record с нужными полями фильма. Records игнорируют неизвестные свойства, поэтому поля, которые amodb добавит позже, не сломают десериализацию.

### Работает ли код с Jackson 3?

Код рассчитан на Jackson 2.x. В Jackson 3 классы databind переехали из com.fasterxml.jackson.databind в tools.jackson.databind, поэтому меняются импорты. Код с HttpClient остается точно таким же.

## Связанные страницы

- [API сезонов и серий сериала](https://amodb.dev/ru/tv-seasons-episodes/): API сезонов и серий сериала с теми же путями и JSON, что в TMDB v3. Читайте сезон со всеми сериями или одну серию с датой выхода, длительностью и кадром.
- [API списка серий](https://amodb.dev/ru/episode-guide-api/): API списка серий на эндпоинтах сезонов и серий TMDB v3: названия, даты выхода, длительность, кадры и группы серий для любого сериала, на 14 языках.
- [API сериалов](https://amodb.dev/ru/tv-series/): API сериалов с теми же путями и JSON, что в TMDB v3. Сериал по id с сезонами, телесетями и следующей серией, а также возрастные рейтинги и сервисы просмотра.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.
- [Ошибки](https://amodb.dev/ru/errors/): Все ошибки amodb: HTTP-статус, status_code и сообщение TMDB, а также формат ошибки OMDb. Что означает каждая и что должен делать ваш клиент дальше.
- [Руководство по Go API фильмов](https://amodb.dev/ru/go-movie-api/): Соберите на Go утилиту, которая выводит лучшие фильмы жанра и года: типизированные структуры, повтор при 429 по Retry-After и кэш ответов на диске.

Обновлено: 2026-10-18. Source: https://amodb.dev/ru/java-movie-api/
