# Java movie API tutorial

> Build a Java command line episode guide that prints every episode of a TV season with number, air date and runtime. It uses the JDK HttpClient and Jackson, maps JSON to records, retries a 429 after the Retry-After delay and caches replies in files. It needs Java 17 or newer and Maven 3.9.

You will build `EpisodeGuide`, a command line program. Run it with a TV series id and a season number and it prints each episode with number, air date, runtime and name. It uses the JDK `HttpClient`, Jackson 2.x, Java 17 or newer and Maven 3.9. Plan on 20 minutes.

## Step 1: Get an API key

A key needs a prepaid plan, so start on [pricing](https://amodb.dev/pricing/). All plans serve the same data, and only the per-minute rate differs. Create the key in your account dashboard and export it:

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

The program reads `System.getenv("AMODB_KEY")` and refuses to start without it.

## Step 2: Create the project

Jackson is the only dependency. Create the folder, a minimal `pom.xml` and the source directory:

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

A newer 2.x release of `jackson-databind` works as well.

## Step 3: Send the first request

The season endpoint is `GET /3/tv/{tv_id}/season/{season_number}`, and Breaking Bad is series `1396`. This class prints the status code. Save it as `src/main/java/First.java` to try it alone. The run command in step 7 works for it too if you set `-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());
    }
}
```

## Step 4: Map the JSON to records

A season reply has a `name` and an `episodes` array. Each episode has `episode_number`, `name`, `air_date` and `runtime`, and the last two can be `null` for an episode that has not aired. Use `Integer` for a number that can be missing. The full field list is on [seasons and episodes](https://amodb.dev/tv-seasons-episodes/).

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

## Step 5: Handle errors and rate limits

Read the status before you touch the body.

| Status                        | What the program does                                                                        |
| ----------------------------- | -------------------------------------------------------------------------------------------- |
| 200                           | Returns the body for Jackson.                                                                |
| 401                           | Throws `IOException` naming `AMODB_KEY`. A bad key fails every call, so there is no retry.   |
| 404                           | Throws with the `status_message`, for example when the season does not exist.                |
| 429                           | Reads `Retry-After`, sleeps 1 to 30 seconds and sends the request again, 3 attempts at most. |
| Timeout or connection failure | Rethrown as `network error` with the cause attached.                                         |

The error body is the TMDB shape, so `status_message` is the text to show. The [errors](https://amodb.dev/errors/) page lists every code, and [rate limits](https://amodb.dev/rate-limits/) explains the 60 second window.

## Step 6: Cache replies in files

The edge keeps details for 6 hours, so an episode list rarely changes between runs. The program writes each body to a file in the temp directory, named from the request path, and reads it back while the file is younger than the TTL. A cache hit sends nothing and uses none of your requests per minute.

## Step 7: The complete program

Save this as `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 and run it for season 1 of series 1396:

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

The output has one header line with the season name and the episode count, then one row per episode. An episode without an air date shows `TBA`, and one without a runtime shows `?`. Run the command twice: the second run reads the file from the temp directory and sends no request, until 6 hours have passed. Delete the `amodb-cache` folder to force fresh data.

Keep the `HttpClient` and the `ObjectMapper` in static fields. Both are thread safe and expensive to build, so one instance per process is the normal pattern, including in a web service.

## Next steps

- Loop from season 1 to `number_of_seasons` from [TV series details](https://amodb.dev/tv-series/) and print the whole show.
- Show the next episode of a running series with `next_episode_to_air`.
- Add a third argument for the `language` parameter, and see which tags are served on [languages](https://amodb.dev/languages/).
- Compare with the same pattern in [Go](https://amodb.dev/go-movie-api/), or start from the [episode guide API](https://amodb.dev/episode-guide-api/) overview.

## Questions and answers

### Which Java version and dependencies do I need?

Java 17 or newer. The code uses records, the java.net.http client from the JDK and Files.readString. The only dependency is Jackson databind 2.x, which pulls in jackson-core and jackson-annotations by itself.

### Why use the JDK client and not a third party HTTP library?

It covers everything this tool needs: per request timeouts, headers and status codes, with no extra jar. Create one HttpClient and reuse it for the whole process, because the instance owns the connection pool.

### How does the program handle a 429?

It reads Retry-After with firstValueAsLong, clamps the value to between 1 and 30 seconds, sleeps and sends the same request again, for at most 3 attempts. A 401 is thrown at once, with no retry.

### Can I reuse the code for movies?

Yes. Change the path to /movie/{id} and declare a record with the movie fields you need. Because the records ignore unknown properties, fields that amodb adds later cannot break deserialization.

### Does the code work with Jackson 3?

It targets Jackson 2.x. Jackson 3 moved the databind classes from com.fasterxml.jackson.databind to tools.jackson.databind, so the imports change there. The HttpClient code stays exactly the same.

## Related pages

- [TV seasons and episodes API](https://amodb.dev/tv-seasons-episodes/): TV seasons and episodes API with the same paths and JSON as TMDB v3. Read a season with all its episodes, or one episode with air date, runtime and still.
- [Episode guide API](https://amodb.dev/episode-guide-api/): Episode guide API with TMDB v3 season and episode endpoints: names, air dates, runtimes, stills and episode group lists for any series, in 14 languages.
- [TV series API](https://amodb.dev/tv-series/): TV series API with the same paths and JSON as TMDB v3. Get a show by id with seasons, networks and the next episode, plus content ratings and watch providers.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.
- [Errors](https://amodb.dev/errors/): Every amodb error with its HTTP status, TMDB status_code and message, plus the OMDb error shape. What each one means and what your client should do next.
- [Go movie API tutorial](https://amodb.dev/go-movie-api/): Build a Go command line tool that lists the best-rated movies of a genre and year with typed structs, a 429 retry that honors Retry-After and a disk cache.

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