# Návod na filmové API v Javě

> Vytvořte v Javě průvodce epizodami pro příkazový řádek, který vypíše každou epizodu sezóny seriálu s číslem, datem vysílání a délkou. Používá JDK HttpClient a Jackson, mapuje JSON na záznamy, po 429 opakuje pokus po prodlevě z Retry-After a cachuje odpovědi v souborech. Vyžaduje Javu 17 nebo novější a Maven 3.9.

Vytvoříte `EpisodeGuide`, program pro příkazový řádek. Spusťte ho s ID seriálu a číslem sezóny a vypíše každou epizodu s číslem, datem vysílání, délkou a názvem. Používá `HttpClient` z JDK, Jackson 2.x, Javu 17 nebo novější a Maven 3.9. Počítejte s 20 minutami.

## Krok 1: Získejte API klíč

Klíč vyžaduje předplacený tarif, začněte proto na stránce [ceník](https://amodb.dev/cs/pricing/). Všechny tarify poskytují stejná data a liší se jen rychlost za minutu. Vytvořte klíč v panelu účtu a exportujte ho:

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

Program čte `System.getenv("AMODB_KEY")` a bez něj odmítne start.

## Krok 2: Vytvořte projekt

Jackson je jediná závislost. Vytvořte složku, minimální `pom.xml` a zdrojový adresář:

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

Funguje i novější vydání 2.x `jackson-databind`.

## Krok 3: Odešlete první požadavek

Endpoint sezóny je `GET /3/tv/{tv_id}/season/{season_number}` a Breaking Bad je seriál `1396`. Tato třída vypíše stavový kód. Uložte ji jako `src/main/java/First.java`, abyste ji vyzkoušeli samostatně. Příkaz ke spuštění v kroku 7 funguje i pro ni, když nastavíte `-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: Namapujte JSON na záznamy

Odpověď pro sezónu má `name` a pole `episodes`. Každá epizoda má `episode_number`, `name`, `air_date` a `runtime` a poslední dvě mohou být `null` u epizody, která se ještě nevysílala. Pro číslo, které může chybět, použijte `Integer`. Úplný seznam polí je na stránce [sezóny a epizody](https://amodb.dev/cs/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: Ošetřete chyby a limity požadavků

Přečtěte stav dřív, než sáhnete na tělo.

| Stav                           | Co dělá program                                                                                        |
| ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| 200                            | Vrátí tělo pro Jackson.                                                                                |
| 401                            | Vyhodí `IOException`, která uvádí `AMODB_KEY`. Špatný klíč selže u každého volání, takže se neopakuje. |
| 404                            | Vyhodí výjimku se `status_message`, například když sezóna neexistuje.                                  |
| 429                            | Přečte `Retry-After`, počká 1 až 30 sekund a pošle požadavek znovu, nejvýše 3 pokusy.                  |
| Timeout nebo selhání připojení | Znovu vyhozeno jako `network error` s připojenou příčinou.                                             |

Tělo chyby má tvar TMDB, takže text k zobrazení je `status_message`. Stránka [chyby](https://amodb.dev/cs/errors/) uvádí všechny kódy a [limity požadavků](https://amodb.dev/cs/rate-limits/) vysvětlují 60sekundové okno.

## Krok 6: Ukládejte odpovědi do souborů

Edge drží detaily 6 hodin, takže se seznam epizod mezi spuštěními mění zřídka. Program zapíše každé tělo do souboru v adresáři temp, pojmenovaného podle cesty požadavku, a čte ho zpět, dokud je soubor mladší než TTL. Zásah cache neposílá nic a nespotřebuje žádný z vašich požadavků za minutu.

## Krok 7: Kompletní program

Uložte 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);
        }
    }
}
```

Přeložte a spusťte ho pro sezónu 1 seriálu 1396:

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

Výstup má jeden řádek záhlaví s názvem sezóny a počtem epizod, pak jeden řádek na epizodu. Epizoda bez data vysílání ukazuje `TBA` a bez délky `?`. Spusťte příkaz dvakrát: druhé spuštění přečte soubor z adresáře temp a neposílá žádný požadavek, dokud neuplyne 6 hodin. Smažte složku `amodb-cache`, abyste vynutili čerstvá data.

Držte `HttpClient` a `ObjectMapper` ve statických polích. Oba jsou thread safe a drahé na vytvoření, takže jedna instance na proces je běžný vzor, včetně webové služby.

## Další kroky

- Projděte smyčkou sezóny od 1 do `number_of_seasons` z [detailů seriálu](https://amodb.dev/cs/tv-series/) a vypište celý seriál.
- Ukažte další epizodu běžícího seriálu pomocí `next_episode_to_air`.
- Přidejte třetí argument pro parametr `language` a podívejte se, které tagy se poskytují, na stránce [jazyky](https://amodb.dev/cs/languages/).
- Porovnejte stejný vzor v [Go](https://amodb.dev/cs/go-movie-api/) nebo začněte přehledem [API průvodce epizodami](https://amodb.dev/cs/episode-guide-api/).

## Otázky a odpovědi

### Kterou verzi Javy a jaké závislosti potřebuji?

Javu 17 nebo novější. Kód používá záznamy (records), klienta java.net.http z JDK a Files.readString. Jedinou závislostí je Jackson databind 2.x, který si jackson-core a jackson-annotations stáhne sám.

### Proč klient z JDK, a ne knihovnu HTTP třetí strany?

Pokrývá vše, co tento nástroj potřebuje: timeouty pro jednotlivé požadavky, hlavičky a stavové kódy, bez dalšího jaru. Vytvořte jeden HttpClient a používejte ho pro celý proces, protože instance vlastní fond připojení.

### Jak program zvládá 429?

Čte Retry-After pomocí firstValueAsLong, hodnotu omezí na 1 až 30 sekund, počká a pošle stejný požadavek znovu, nejvýše 3 pokusy. 401 se vyhodí hned, bez opakování.

### Mohu kód znovu použít pro filmy?

Ano. Změňte cestu na /movie/{id} a deklarujte záznam s poli filmu, která potřebujete. Protože záznamy ignorují neznámé vlastnosti, pole, která amodb přidá později, deserializaci nerozbijí.

### Funguje kód s Jackson 3?

Cílí na Jackson 2.x. Jackson 3 přesunul třídy databind z com.fasterxml.jackson.databind do tools.jackson.databind, takže se tam importy mění. Kód HttpClient zůstává úplně stejný.

## Související stránky

- [API sezón a epizod seriálů](https://amodb.dev/cs/tv-seasons-episodes/): API sezón a epizod seriálů se stejnými cestami a JSON jako TMDB v3. Načtěte sezónu se všemi epizodami nebo jednu epizodu s datem vysílání, délkou a záběrem.
- [API průvodce epizodami](https://amodb.dev/cs/episode-guide-api/): API průvodce epizodami s endpointy sezón a epizod TMDB v3: názvy, data vysílání, délky, záběry a seznamy skupin epizod pro libovolný seriál ve 14 jazycích.
- [API detailu seriálu](https://amodb.dev/cs/tv-series/): API detailu seriálu se stejnými cestami a JSON jako TMDB v3. Seriál podle ID se sezónami, sítěmi, věkovým hodnocením a poskytovateli streamování.
- [Limity požadavků](https://amodb.dev/cs/rate-limits/): amodb omezuje klíč počtem požadavků za minutu v klouzavém okně 60 sekund: 20, 40 nebo 120 podle tarifu. Čtěte hlavičky limitu a řešte 429 pomocí Retry-After.
- [Chyby](https://amodb.dev/cs/errors/): Každá chyba amodb se stavem HTTP, status_code a zprávou TMDB a také tvar chyby OMDb. Co která znamená a co má váš klient udělat v dalším kroku.
- [Návod na filmové API v Go](https://amodb.dev/cs/go-movie-api/): Vytvořte v Go nástroj příkazového řádku pro nejlépe hodnocené filmy žánru a roku, s typovanými strukturami, opakováním podle Retry-After a cache na disku.

Aktualizováno: 2026-10-18. Source: https://amodb.dev/cs/java-movie-api/
