# Посібник з Java API фільмів

> Створіть консольний гід епізодів на Java, який друкує всі епізоди сезону серіалу з номером, датою виходу й тривалістю. Він використовує HttpClient з JDK та Jackson, зіставляє JSON із записами, повторює запит після 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/uk/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
```

Новіший реліз 2.x для `jackson-databind` теж підійде.

## Крок 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 із записами

Відповідь про сезон містить `name` і масив `episodes`. Кожен епізод має `episode_number`, `name`, `air_date` і `runtime`, причому останні два можуть бути `null` для епізоду, що ще не вийшов. Для числа, якого може не бути, використовуйте `Integer`. Повний список полів наведено на сторінці [сезони та серії](https://amodb.dev/uk/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/uk/errors/) перелічує всі коди, а [ліміти запитів](https://amodb.dev/uk/rate-limits/) пояснюють 60-секундне вікно.

## Крок 6: кешуйте відповіді у файлах

Кеш на периферії зберігає деталі 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/uk/tv-series/) і надрукуйте все шоу.
- Покажіть наступний епізод серіалу, що виходить, через `next_episode_to_air`.
- Додайте третій аргумент для параметра `language`, а які теги підтримуються, дивіться на сторінці [мови](https://amodb.dev/uk/languages/).
- Порівняйте з тим самим підходом на [Go](https://amodb.dev/uk/go-movie-api/) або почніть з огляду [API гіда епізодів](https://amodb.dev/uk/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} і оголосіть запис із потрібними полями фільму. Оскільки записи ігнорують невідомі властивості, поля, які amodb додасть пізніше, не зламають десеріалізацію.

### Чи працює код із Jackson 3?

Він розрахований на Jackson 2.x. У Jackson 3 класи databind переїхали з com.fasterxml.jackson.databind до tools.jackson.databind, тому імпорти там змінюються. Код HttpClient лишається точно таким самим.

## Пов'язані сторінки

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

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