# Урок за Java и 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/bg/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 в records

Отговорът за сезон има `name` и масив `episodes`. Всеки епизод има `episode_number`, `name`, `air_date` и `runtime`, като последните две могат да са `null` за епизод, който още не е излъчен. Ползвайте `Integer` за число, което може да липсва. Пълният списък с полета е на страницата [сезони и епизоди](https://amodb.dev/bg/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/bg/errors/) изброява всички кодове, а [лимити на заявките](https://amodb.dev/bg/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/bg/tv-series/) и отпечатайте целия сериал.
- Показвайте следващия епизод на излъчван сериал с `next_episode_to_air`.
- Добавете трети аргумент за параметъра `language`, а кои тагове се поддържат, вижте на страницата [езици](https://amodb.dev/bg/languages/).
- Сравнете със същия подход на [Go](https://amodb.dev/bg/go-movie-api/) или започнете от прегледа [API за справочник по епизоди](https://amodb.dev/bg/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/bg/tv-seasons-episodes/): API за сезони и епизоди със същите пътища и JSON като TMDB v3. Четете сезон с всичките му епизоди или един епизод с дата на излъчване, продължителност и кадър.
- [API за справочник по епизоди](https://amodb.dev/bg/episode-guide-api/): API за справочник по епизоди с ендпойнтите за сезон и епизод на TMDB v3: имена, дати, продължителност, кадри и групи епизоди за всеки сериал, на 14 езика.
- [API за сериали](https://amodb.dev/bg/tv-series/): API за сериали със същите пътища и JSON като TMDB v3. Сериал по id със сезони, мрежи и следващ епизод, плюс възрастови рейтинги и доставчици за гледане.
- [Лимити на заявките](https://amodb.dev/bg/rate-limits/): amodb ограничава всеки ключ по заявки в минута в плъзгащ се прозорец от 60 секунди: 20, 40 или 120. Заглавки за лимита и обработка на 429 с Retry-After.
- [Грешки](https://amodb.dev/bg/errors/): Всяка грешка на amodb с HTTP статус, status_code на TMDB и съобщение, плюс формата на грешките на OMDb. Какво означава всяка и какво да направи клиентът ви.
- [Урок за Go и API за филми](https://amodb.dev/bg/go-movie-api/): Създайте Go инструмент за командния ред, който изброява най-високо оценените филми по жанр и година с типизирани структури, повторение при 429 и дисков кеш.

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