# 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 密钥

密钥需要预付套餐，所以请先从[定价](https://amodb.dev/zh/pricing/)页开始。所有套餐的数据相同，只有每分钟速率不同。在账户后台创建密钥并导出：

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

程序读取 `System.getenv("AMODB_KEY")`，没有它就拒绝启动。

## 步骤 2：创建项目

Jackson 是唯一的依赖。创建文件夹、一个最小的 `pom.xml` 和源码目录：

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

更新的 2.x 版本的 `jackson-databind` 同样可用。

## 步骤 3：发送第一个请求

季接口是 `GET /3/tv/{tv_id}/season/{season_number}`，《绝命毒师》的剧集 ID 是 `1396`。这个类会打印状态码。把它保存为 `src/main/java/First.java` 即可单独试用。如果设置 `-Dexec.mainClass=First`，步骤 7 中的运行命令同样适用。

```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());
    }
}
```

## 步骤 4：把 JSON 映射为 record

季响应包含一个 `name` 和一个 `episodes` 数组。每一集有 `episode_number`、`name`、`air_date` 和 `runtime`，对于尚未播出的剧集，后两者可能是 `null`。对可能缺失的数字使用 `Integer`。完整的字段列表见[季与集](https://amodb.dev/zh/tv-seasons-episodes/)。

```java
@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` 就是要显示的文本。[错误](https://amodb.dev/zh/errors/)页列出了所有错误码，[速率限制](https://amodb.dev/zh/rate-limits/)说明了 60 秒窗口。

## 步骤 6：把响应缓存到文件

边缘缓存详情 6 小时，所以两次运行之间剧集列表很少变化。程序把每个响应体写入临时目录中的文件，文件名取自请求路径，并在文件比 TTL 更新时读取它。命中缓存时不发送任何请求，也不消耗你每分钟的请求数。

## 步骤 7：完整程序

把它保存为 `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);
        }
    }
}
```

为剧集 1396 的第 1 季编译并运行：

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

输出先是一行包含季名称和集数的标题，然后每集一行。没有播出日期的剧集显示 `TBA`，没有时长的显示 `?`。把命令运行两次：第二次从临时目录读取文件，不发送请求，直到 6 小时过去。删除 `amodb-cache` 文件夹可强制获取最新数据。

请把 `HttpClient` 和 `ObjectMapper` 放在静态字段中。两者都是线程安全的，而且创建成本高，所以每个进程一个实例是常规做法，Web 服务中也一样。

## 下一步

- 根据[电视剧详情](https://amodb.dev/zh/tv-series/)中的 `number_of_seasons`，从第 1 季循环到最后一季，打印整部剧集。
- 用 `next_episode_to_air` 显示连载剧集的下一集。
- 添加第三个参数用于 `language` 参数，并在[语言](https://amodb.dev/zh/languages/)页查看提供哪些标签。
- 与 [Go](https://amodb.dev/zh/go-movie-api/) 中的同一模式比较，或从[剧集指南 API](https://amodb.dev/zh/episode-guide-api/)概览开始。

## 常见问题

### 需要哪个 Java 版本和哪些依赖？

Java 17 或更新版本。代码使用 record、JDK 自带的 java.net.http 客户端和 Files.readString。唯一的依赖是 Jackson databind 2.x，它会自动引入 jackson-core 和 jackson-annotations。

### 为什么用 JDK 客户端而不是第三方 HTTP 库？

它覆盖了这个工具所需的一切：每个请求的超时、请求头和状态码，无需额外的 jar。创建一个 HttpClient 并在整个进程中复用，因为该实例持有连接池。

### 程序如何处理 429？

它用 firstValueAsLong 读取 Retry-After，把值限制在 1 到 30 秒之间，休眠后重新发送同一个请求，最多 3 次。401 会立即抛出，不重试。

### 代码能复用到电影上吗？

能。把路径改为 /movie/{id}，并声明一个包含你所需电影字段的 record。由于 record 会忽略未知属性，amodb 以后新增的字段不会让反序列化出错。

### 代码适用于 Jackson 3 吗？

它面向 Jackson 2.x。Jackson 3 把 databind 类从 com.fasterxml.jackson.databind 移到了 tools.jackson.databind，所以导入语句要修改。HttpClient 部分的代码完全不变。

## 相关页面

- [电视剧季与单集 API](https://amodb.dev/zh/tv-seasons-episodes/): 路径和 JSON 与 TMDB v3 相同的电视剧季与单集 API。读取一季及其全部单集，或读取单集的播出日期、时长和剧照。
- [剧集指南 API](https://amodb.dev/zh/episode-guide-api/): 基于 TMDB v3 季与集接口的剧集指南 API：任意剧集的片名、播出日期、时长、剧照和剧集分组列表，支持 14 种语言。
- [电视剧 API](https://amodb.dev/zh/tv-series/): 路径和 JSON 与 TMDB v3 相同的电视剧 API。按 ID 获取剧集的季、电视网和下一集，以及内容分级和观看平台。
- [速率限制](https://amodb.dev/zh/rate-limits/): amodb 按滑动 60 秒窗口内每分钟请求数限制每个密钥：按套餐为 20、40 或 120。读取速率限制响应头，并用 Retry-After 处理 429。
- [错误](https://amodb.dev/zh/errors/): amodb 的每个错误及其 HTTP 状态、TMDB status_code 和消息，以及 OMDb 错误结构。每个错误的含义和客户端下一步该做什么。
- [Go 电影 API 教程](https://amodb.dev/zh/go-movie-api/): 用 Go 构建命令行工具，按类型和年份列出评分最高的电影：类型化结构体、遵循 Retry-After 的 429 重试和磁盘缓存。

更新于: 2026-10-18. Source: https://amodb.dev/zh/java-movie-api/
