# Tutorial de API de filmes em Java

> Crie um guia de episódios em Java, de linha de comando, que imprime cada episódio de uma temporada de série com número, data de exibição e duração. Usa HttpClient do JDK e Jackson, mapeia o JSON em records, repete um 429 após o atraso de Retry-After e guarda respostas em arquivos. Exige Java 17 ou mais recente e Maven 3.9.

Você vai construir `EpisodeGuide`, um programa de linha de comando. Execute-o com o id de uma série de TV e o número de uma temporada, e ele imprime cada episódio com número, data de exibição, duração e nome. Usa o `HttpClient` do JDK, o Jackson 2.x, Java 17 ou mais recente e Maven 3.9. Conte com 20 minutos.

## Passo 1: obtenha uma chave de API

Uma chave exige um plano pré-pago, então comece em [preços](https://amodb.dev/pt/pricing/). Todos os planos servem os mesmos dados, e só a taxa por minuto muda. Crie a chave no painel da sua conta e exporte-a:

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

O programa lê `System.getenv("AMODB_KEY")` e se recusa a iniciar sem ela.

## Passo 2: crie o projeto

O Jackson é a única dependência. Crie a pasta, um `pom.xml` mínimo e o diretório de código-fonte:

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

Uma versão 2.x mais nova do `jackson-databind` também funciona.

## Passo 3: envie a primeira requisição

O endpoint de temporada é `GET /3/tv/{tv_id}/season/{season_number}`, e Breaking Bad é a série `1396`. Esta classe imprime o código de status. Salve-a como `src/main/java/First.java` para testá-la sozinha. O comando de execução do passo 7 também funciona para ela, se você definir `-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());
    }
}
```

## Passo 4: mapeie o JSON em records

A resposta de uma temporada tem um `name` e um array `episodes`. Cada episódio tem `episode_number`, `name`, `air_date` e `runtime`, e os dois últimos podem ser `null` em um episódio que ainda não foi ao ar. Use `Integer` para um número que pode faltar. A lista completa de campos está em [temporadas e episódios](https://amodb.dev/pt/tv-seasons-episodes/).

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

## Passo 5: trate erros e limites de requisições

Leia o status antes de tocar no corpo.

| Status                      | O que o programa faz                                                                                       |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 200                         | Retorna o corpo para o Jackson.                                                                            |
| 401                         | Lança `IOException` citando `AMODB_KEY`. Uma chave inválida falha em toda chamada, então não há repetição. |
| 404                         | Lança com o `status_message`, por exemplo quando a temporada não existe.                                   |
| 429                         | Lê `Retry-After`, espera de 1 a 30 segundos e envia a requisição de novo, no máximo 3 tentativas.          |
| Timeout ou falha de conexão | Relançado como `network error` com a causa anexada.                                                        |

O corpo do erro tem o formato do TMDB, então `status_message` é o texto a mostrar. A página de [erros](https://amodb.dev/pt/errors/) lista todos os códigos, e [limites de requisições](https://amodb.dev/pt/rate-limits/) explica a janela de 60 segundos.

## Passo 6: guarde as respostas em arquivos

A borda guarda os detalhes por 6 horas, então uma lista de episódios raramente muda entre execuções. O programa grava cada corpo em um arquivo no diretório temporário, nomeado a partir do caminho da requisição, e o lê de volta enquanto o arquivo for mais novo que o TTL. Um acerto de cache não envia nada e não usa nenhuma das suas requisições por minuto.

## Passo 7: o programa completo

Salve isto como `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);
        }
    }
}
```

Compile e execute para a temporada 1 da série 1396:

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

A saída tem uma linha de cabeçalho com o nome da temporada e a contagem de episódios, depois uma linha por episódio. Um episódio sem data de exibição mostra `TBA`, e um sem duração mostra `?`. Execute o comando duas vezes: a segunda execução lê o arquivo do diretório temporário e não envia nenhuma requisição, até passarem 6 horas. Apague a pasta `amodb-cache` para forçar dados novos.

Mantenha o `HttpClient` e o `ObjectMapper` em campos estáticos. Os dois são thread safe e caros de construir, então uma instância por processo é o padrão normal, inclusive em um serviço web.

## Próximos passos

- Percorra da temporada 1 até `number_of_seasons`, obtido nos [detalhes de séries de TV](https://amodb.dev/pt/tv-series/), e imprima a série inteira.
- Mostre o próximo episódio de uma série em exibição com `next_episode_to_air`.
- Adicione um terceiro argumento para o parâmetro `language` e veja quais tags são aceitas em [idiomas](https://amodb.dev/pt/languages/).
- Compare com o mesmo padrão em [Go](https://amodb.dev/pt/go-movie-api/) ou comece pela visão geral da [API de guia de episódios](https://amodb.dev/pt/episode-guide-api/).

## Perguntas e respostas

### Qual versão do Java e quais dependências eu preciso?

Java 17 ou mais recente. O código usa records, o cliente java.net.http do JDK e Files.readString. A única dependência é o Jackson databind 2.x, que traz jackson-core e jackson-annotations por conta própria.

### Por que usar o cliente do JDK e não uma biblioteca HTTP de terceiros?

Ele cobre tudo o que esta ferramenta precisa: timeouts por requisição, cabeçalhos e códigos de status, sem nenhum jar extra. Crie um HttpClient e reutilize-o em todo o processo, porque a instância é dona do pool de conexões.

### Como o programa trata um 429?

Ele lê Retry-After com firstValueAsLong, limita o valor entre 1 e 30 segundos, espera e envia a mesma requisição de novo, em no máximo 3 tentativas. Um 401 é lançado na hora, sem repetição.

### Posso reutilizar o código para filmes?

Sim. Troque o caminho por /movie/{id} e declare um record com os campos de filme de que você precisa. Como os records ignoram propriedades desconhecidas, campos que o amodb adicionar depois não quebram a desserialização.

### O código funciona com o Jackson 3?

Ele mira o Jackson 2.x. O Jackson 3 moveu as classes databind de com.fasterxml.jackson.databind para tools.jackson.databind, então os imports mudam. O código do HttpClient continua exatamente igual.

## Páginas relacionadas

- [API de temporadas e episódios de séries](https://amodb.dev/pt/tv-seasons-episodes/): API de temporadas e episódios com os mesmos caminhos e JSON do TMDB v3. Leia uma temporada com todos os episódios, ou um episódio com data, duração e still.
- [API de guia de episódios](https://amodb.dev/pt/episode-guide-api/): API de guia de episódios com os endpoints de temporada e episódio do TMDB v3: nomes, datas de exibição, durações, imagens e grupos de episódios, em 14 idiomas.
- [API de séries de TV](https://amodb.dev/pt/tv-series/): API de séries de TV com os mesmos caminhos e JSON do TMDB v3. Busque uma série por id com temporadas, emissoras, próximo episódio e classificações.
- [Limites de requisições](https://amodb.dev/pt/rate-limits/): O amodb limita cada chave por requisições por minuto em uma janela de 60 segundos: 20, 40 ou 120 por plano. Leia os cabeçalhos e trate o 429 com Retry-After.
- [Erros](https://amodb.dev/pt/errors/): Todos os erros do amodb com status HTTP, status_code e mensagem do TMDB, mais o formato de erro do OMDb. O que cada um significa e o que seu cliente deve fazer.
- [Tutorial de API de filmes em Go](https://amodb.dev/pt/go-movie-api/): Crie uma ferramenta de linha de comando em Go que lista filmes mais bem avaliados por gênero e ano, com structs tipadas, Retry-After em 429 e cache em disco.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/java-movie-api/
