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.

.java Java Updated View as Markdown

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. All plans serve the same data, and only the per-minute rate differs. Create the key in your account dashboard and export it:

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

Shell
export AMODB_KEY="YOUR_API_KEY"

Step 2: Create the project

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

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

Shell
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

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.

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.

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

The error body is the TMDB shape, so status_message is the text to show. The errors page lists every code, and 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:

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.

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

Next steps

  • Loop from season 1 to number_of_seasons from TV series details 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.
  • Compare with the same pattern in Go, or start from the episode guide API overview.