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. 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_ and refuses to start without it.
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.
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>
EOFStep 3: Send the first request
The season endpoint is GET /, and Breaking Bad is series 1396. This class prints the status code. Save it as src/ to try it alone. The run command in step 7 works for it too if you set -Dexec.mainClass=First.
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.
@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 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/.
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.
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"Next steps
- Loop from season 1 to
number_of_seasonsfrom 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
languageparameter, and see which tags are served on languages. - Compare with the same pattern in Go, or start from the episode guide API overview.