# Poradnik API filmów w Javie

> Zbuduj w Javie przewodnik po odcinkach dla wiersza poleceń, który wypisuje każdy odcinek sezonu serialu z numerem, datą emisji i czasem trwania. Używa JDK HttpClient i Jackson, mapuje JSON na rekordy, ponawia żądanie po 429 po opóźnieniu z Retry-After i zapisuje odpowiedzi w plikach cache. Wymaga Javy 17 lub nowszej i Mavena 3.9.

Zbudujesz `EpisodeGuide`, program wiersza poleceń. Uruchom go z ID serialu i numerem sezonu, a wypisze każdy odcinek z numerem, datą emisji, czasem trwania i nazwą. Używa `HttpClient` z JDK, Jackson 2.x, Javy 17 lub nowszej i Mavena 3.9. Zaplanuj 20 minut.

## Krok 1: Zdobądź klucz API

Klucz wymaga planu z przedpłatą, więc zacznij od strony [cennik](https://amodb.dev/pl/pricing/). Wszystkie plany dają te same dane, a różni je tylko szybkość na minutę. Utwórz klucz w panelu konta i wyeksportuj go:

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

Program czyta `System.getenv("AMODB_KEY")` i odmawia startu bez niego.

## Krok 2: Utwórz projekt

Jackson jest jedyną zależnością. Utwórz folder, minimalny `pom.xml` i katalog źródłowy:

```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
```

Nowsze wydanie 2.x `jackson-databind` też działa.

## Krok 3: Wyślij pierwsze żądanie

Endpoint sezonu to `GET /3/tv/{tv_id}/season/{season_number}`, a Breaking Bad to serial `1396`. Ta klasa wypisuje kod statusu. Zapisz ją jako `src/main/java/First.java`, by wypróbować ją osobno. Polecenie uruchomienia z kroku 7 działa też dla niej, jeśli ustawisz `-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());
    }
}
```

## Krok 4: Zmapuj JSON na rekordy

Odpowiedź dla sezonu ma `name` i tablicę `episodes`. Każdy odcinek ma `episode_number`, `name`, `air_date` i `runtime`, a ostatnie dwa mogą być `null` dla odcinka, który się jeszcze nie ukazał. Użyj `Integer` dla liczby, której może brakować. Pełna lista pól jest na stronie [sezony i odcinki](https://amodb.dev/pl/tv-seasons-episodes/).

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

## Krok 5: Obsłuż błędy i limity żądań

Sprawdź status, zanim dotkniesz treści.

| Status                        | Co robi program                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| 200                           | Zwraca treść dla Jackson.                                                                                    |
| 401                           | Rzuca `IOException` wskazujący `AMODB_KEY`. Zły klucz zawodzi przy każdym wywołaniu, więc nie ma ponawiania. |
| 404                           | Rzuca wyjątek z `status_message`, na przykład gdy sezon nie istnieje.                                        |
| 429                           | Czyta `Retry-After`, śpi od 1 do 30 sekund i wysyła żądanie ponownie, najwyżej 3 próby.                      |
| Timeout lub awaria połączenia | Rzucane ponownie jako `network error` z dołączoną przyczyną.                                                 |

Treść błędu ma kształt TMDB, więc tekstem do pokazania jest `status_message`. Strona [błędy](https://amodb.dev/pl/errors/) wymienia wszystkie kody, a [limity żądań](https://amodb.dev/pl/rate-limits/) wyjaśniają 60-sekundowe okno.

## Krok 6: Zapisuj odpowiedzi w plikach

Edge trzyma szczegóły przez 6 godzin, więc lista odcinków rzadko zmienia się między uruchomieniami. Program zapisuje każdą treść do pliku w katalogu temp, nazwanego od ścieżki żądania, i czyta ją z powrotem, dopóki plik jest młodszy niż TTL. Trafienie w cache nic nie wysyła i nie zużywa żadnego z twoich żądań na minutę.

## Krok 7: Kompletny program

Zapisz to jako `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);
        }
    }
}
```

Skompiluj i uruchom go dla sezonu 1 serialu 1396:

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

Wynik ma jedną linię nagłówka z nazwą sezonu i liczbą odcinków, potem jeden wiersz na odcinek. Odcinek bez daty emisji pokazuje `TBA`, a bez czasu trwania `?`. Uruchom polecenie dwa razy: drugie uruchomienie czyta plik z katalogu temp i nie wysyła żądania, dopóki nie minie 6 godzin. Usuń folder `amodb-cache`, by wymusić świeże dane.

Trzymaj `HttpClient` i `ObjectMapper` w polach statycznych. Oba są bezpieczne wątkowo i drogie w budowie, więc jedna instancja na proces to normalny wzorzec, także w usłudze webowej.

## Kolejne kroki

- Przejdź pętlą od sezonu 1 do `number_of_seasons` ze [szczegółów serialu](https://amodb.dev/pl/tv-series/) i wypisz cały serial.
- Pokaż następny odcinek emitowanego serialu przez `next_episode_to_air`.
- Dodaj trzeci argument dla parametru `language` i zobacz, które tagi są obsługiwane, na stronie [języki](https://amodb.dev/pl/languages/).
- Porównaj ten sam wzorzec w [Go](https://amodb.dev/pl/go-movie-api/) albo zacznij od przeglądu [API przewodnika po odcinkach](https://amodb.dev/pl/episode-guide-api/).

## Pytania i odpowiedzi

### Jakiej wersji Javy i jakich zależności potrzebuję?

Javy 17 lub nowszej. Kod używa rekordów (records), klienta java.net.http z JDK i Files.readString. Jedyną zależnością jest Jackson databind 2.x, który sam ściąga jackson-core i jackson-annotations.

### Dlaczego klient z JDK, a nie zewnętrzna biblioteka HTTP?

Pokrywa wszystko, czego to narzędzie potrzebuje: timeouty na żądanie, nagłówki i kody statusu, bez dodatkowego jara. Utwórz jeden HttpClient i używaj go przez cały proces, bo instancja jest właścicielem puli połączeń.

### Jak program obsługuje 429?

Czyta Retry-After przez firstValueAsLong, ogranicza wartość do przedziału od 1 do 30 sekund, czeka i wysyła to samo żądanie ponownie, najwyżej 3 razy. 401 jest rzucany od razu, bez ponawiania.

### Czy mogę użyć kodu do filmów?

Tak. Zmień ścieżkę na /movie/{id} i zadeklaruj rekord z potrzebnymi polami filmu. Ponieważ rekordy ignorują nieznane właściwości, pola dodane później przez amodb nie zepsują deserializacji.

### Czy kod działa z Jackson 3?

Jest przeznaczony dla Jackson 2.x. Jackson 3 przeniósł klasy databind z com.fasterxml.jackson.databind do tools.jackson.databind, więc tam zmieniają się importy. Kod HttpClient pozostaje dokładnie taki sam.

## Powiązane strony

- [API sezonów i odcinków seriali](https://amodb.dev/pl/tv-seasons-episodes/): API sezonów i odcinków seriali z tymi samymi ścieżkami i JSON co TMDB v3. Odczytaj sezon ze wszystkimi odcinkami albo jeden odcinek z datą emisji i kadrem.
- [API przewodnika po odcinkach](https://amodb.dev/pl/episode-guide-api/): API przewodnika po odcinkach z endpointami TMDB v3: nazwy, daty emisji, czas trwania, kadry i listy grup odcinków dowolnego serialu w 14 językach.
- [API szczegółów serialu](https://amodb.dev/pl/tv-series/): API szczegółów serialu z tymi samymi ścieżkami i JSON co TMDB v3. Serial po ID z sezonami, sieciami, klasyfikacją wiekową i dostawcami streamingu.
- [Limity żądań](https://amodb.dev/pl/rate-limits/): amodb limituje klucz liczbą żądań na minutę w przesuwanym oknie 60 sekund: 20, 40 lub 120 zależnie od planu. Czytaj nagłówki limitu i obsłuż 429 z Retry-After.
- [Błędy](https://amodb.dev/pl/errors/): Każdy błąd amodb ze statusem HTTP, status_code i komunikatem TMDB oraz kształt błędu OMDb. Co oznacza każdy z nich i co powinien zrobić twój klient dalej.
- [Poradnik API filmów w Go](https://amodb.dev/pl/go-movie-api/): Zbuduj w Go narzędzie wiersza poleceń, które wypisuje najlepiej oceniane filmy gatunku i roku, z typowanymi strukturami, ponawianiem po 429 i cache na dysku.

Zaktualizowano: 2026-10-18. Source: https://amodb.dev/pl/java-movie-api/
