Java 电影 API 教程
构建一个 Java 命令行剧集指南,打印一季中每集的编号、播出日期和时长。使用 JDK HttpClient 和 Jackson,把 JSON 映射为 record,按 Retry-After 重试 429,缓存到文件。需要 Java 17。
你将构建 EpisodeGuide,一个命令行程序。传入电视剧 ID 和季号运行它,它会打印每一集的编号、播出日期、时长和名称。它使用 JDK 的 HttpClient、Jackson 2.x、Java 17 或更新版本以及 Maven 3.9。预计用时 20 分钟。
步骤 1:获取 API 密钥
密钥需要预付套餐,所以请先从定价页开始。所有套餐的数据相同,只有每分钟速率不同。在账户后台创建密钥并导出:
程序读取 System.getenv("AMODB_,没有它就拒绝启动。
export AMODB_KEY="YOUR_API_KEY"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步骤 3:发送第一个请求
季接口是 GET /,《绝命毒师》的剧集 ID 是 1396。这个类会打印状态码。把它保存为 src/ 即可单独试用。如果设置 -Dexec.mainClass=First,步骤 7 中的运行命令同样适用。
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());
}
}步骤 4:把 JSON 映射为 record
季响应包含一个 name 和一个 episodes 数组。每一集有 episode_number、name、air_date 和 runtime,对于尚未播出的剧集,后两者可能是 null。对可能缺失的数字使用 Integer。完整的字段列表见季与集。
@JsonIgnoreProperties(ignoreUnknown = true)
public record Episode(
@JsonProperty("episode_number") int number,
String name,
@JsonProperty("air_date") String airDate,
Integer runtime) {}步骤 5:处理错误和速率限制
先读取状态,再处理响应体。
| 状态 | 程序的做法 |
|---|---|
| 200 | 把响应体交给 Jackson。 |
| 401 | 抛出指明 AMODB_KEY 的 IOException。错误的密钥会让每次调用都失败,所以不重试。 |
| 404 | 抛出带有 status_message 的异常,例如该季不存在时。 |
| 429 | 读取 Retry-After,休眠 1 到 30 秒后重新发送请求,最多 3 次。 |
| 超时或连接失败 | 作为 network error 重新抛出,并附上原因。 |
错误响应体采用 TMDB 的结构,所以 status_message 就是要显示的文本。错误页列出了所有错误码,速率限制说明了 60 秒窗口。
步骤 6:把响应缓存到文件
边缘缓存详情 6 小时,所以两次运行之间剧集列表很少变化。程序把每个响应体写入临时目录中的文件,文件名取自请求路径,并在文件比 TTL 更新时读取它。命中缓存时不发送任何请求,也不消耗你每分钟的请求数。
步骤 7:完整程序
把它保存为 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);
}
}
}为剧集 1396 的第 1 季编译并运行:
输出先是一行包含季名称和集数的标题,然后每集一行。没有播出日期的剧集显示 TBA,没有时长的显示 ?。把命令运行两次:第二次从临时目录读取文件,不发送请求,直到 6 小时过去。删除 amodb-cache 文件夹可强制获取最新数据。
请把 HttpClient 和 ObjectMapper 放在静态字段中。两者都是线程安全的,而且创建成本高,所以每个进程一个实例是常规做法,Web 服务中也一样。
mvn -q compile exec:java -Dexec.mainClass=EpisodeGuide -Dexec.args="1396 1"