# 面向 .NET 8 的 C# 电影 API 教程

> 本教程构建一个 .NET 8 控制台应用：接收片名，搜索 amodb，加载最佳匹配及其评分，并打印卡片。只用 HttpClient 和 System.Text.Json，从 AMODB_KEY 读取密钥，429 时重试并缓存到文件。配置约 15 分钟。

## 你将构建什么

你将构建 `MovieLookup`，一个控制台应用：从命令行接收片名，搜索 amodb，加载最佳匹配及其评分，并打印带有海报链接的简短卡片。你需要 .NET 8 SDK、一个 amodb 密钥和大约 15 分钟。程序只使用 `HttpClient` 和 `System.Text.Json`，因此无需安装任何 NuGet 包。

## 步骤 1：获取 API 密钥

在[定价](https://amodb.dev/zh/pricing/)页选择套餐。套餐只在速率上不同，分别为每分钟 20、40 或 120 个请求。在账户后台创建密钥，它是由 32 个小写十六进制字符组成的字符串。导出它，并用一次调用测试：

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $AMODB_KEY" \
  "https://api.amodb.dev/3/movie/550"
```

`200` 表示密钥可用。`401` 表示密钥错误或缺失。在 PowerShell 中用 `$env:AMODB_KEY = "YOUR_API_KEY"` 设置该变量。[认证](https://amodb.dev/zh/authentication/)页介绍了用 `api_key` 查询参数替代请求头的方式。

## 步骤 2：创建项目

```bash
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup
```

.NET 8 模板默认启用隐式 using 和可空引用类型，因此已经导入了 `System`、`System.IO`、`System.Linq` 和 `System.Net.Http`。其他 `using` 行要在文件中自己添加。

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

创建一个 `HttpClient`，设置基础地址、10 秒超时和 Bearer 请求头。基础地址以斜杠结尾，相对路径不要以斜杠开头。开头的斜杠会替换基础路径中的 `/3` 部分。

```csharp
using System.Net.Http.Headers;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AMODB_KEY"));

var response = await http.GetAsync("search/movie?query=fight%20club&language=en-US");
Console.WriteLine((int)response.StatusCode);
```

## 步骤 4：把 JSON 解析为 record

响应使用 `release_date` 这样的蛇形命名。设置 .NET 8 新增的 `JsonNamingPolicy.SnakeCaseLower`，并用 PascalCase 编写成员。未声明的字段会被忽略。

```csharp
record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);
```

`ratings` 对象出现，是因为详情调用加了 `append_to_response=ratings`。它的五个来源各是一个包含 `value` 和 `votes` 的对象，评分缺失时为 `null`。`votes` 也可能是 `null`：《搏击俱乐部》的实际响应中，Kinopoisk、Metacritic 和 Rotten Tomatoes 都没有投票数。其余字段见[电影](https://amodb.dev/zh/movies/)页。

## 步骤 5：处理错误和速率限制

把调用包进一个根据状态码做出反应的循环中。

| 状态        | 含义          | 应用的做法                                             |
| --------- | ----------- | ------------------------------------------------- |
| 401       | 密钥缺失、错误或已停用 | 打印消息并停止，重试没有用。                                    |
| 402       | 套餐周期已结束     | 打印消息并停止。请在[定价](https://amodb.dev/zh/pricing/)页续费。 |
| 404       | 该 ID 没有记录   | 报告并退出。                                            |
| 429       | 超过每分钟请求数    | 等待 `Retry-After` 秒（上限 30 秒），最多重试两次。               |
| 500 或 504 | 服务端故障       | 报告状态，稍后再运行命令。                                     |

```csharp
for (var attempt = 0; ; attempt++)
{
    using var response = await http.GetAsync(path);
    if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
    {
        var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
        await Task.Delay(TimeSpan.FromSeconds(seconds));
        continue;
    }
    if (response.StatusCode == HttpStatusCode.Unauthorized)
        throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
    // ...handle other statuses, then read the body
}
```

网络故障表现为 `HttpRequestException`，超时表现为 `TaskCanceledException`。请在顶层捕获两者。每个带密钥的响应还带有 `X-RateLimit-Remaining`，因此长时间运行的任务可以在到达 429 之前放慢速度。完整列表见[速率限制](https://amodb.dev/zh/rate-limits/)和[错误](https://amodb.dev/zh/errors/)。

## 步骤 6：把响应缓存到磁盘

速率限制检查先于边缘缓存执行，因此由边缘缓存返回的响应仍计入你每分钟的请求数。想节省请求，唯一的办法是在你自己的程序里做缓存。边缘缓存详情 6 小时、搜索结果 10 分钟，所以应用使用相同的有效期。临时文件夹中的文件，写入时间在 TTL 之内就算新鲜。

```csharp
class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }
}
```

## 步骤 7：整合起来

这是完整的 `Program.cs`。它给缓存加上了 `Set` 方法，并加入 `AmodbClient` 类，把缓存、重试循环和 JSON 选项组合在一起。

```csharp
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; // same number format on every machine

var key = Environment.GetEnvironmentVariable("AMODB_KEY");
if (string.IsNullOrWhiteSpace(key))
{
    Console.Error.WriteLine("Set AMODB_KEY first.");
    return 1;
}

var title = string.Join(' ', args).Trim();
if (title.Length == 0)
{
    Console.Error.WriteLine("Usage: dotnet run -- \"movie title\"");
    return 1;
}

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.amodb.dev/3/"),
    Timeout = TimeSpan.FromSeconds(10),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
var client = new AmodbClient(http, new FileCache());

try
{
    var search = await client.GetAsync<SearchResponse>(
        $"search/movie?query={Uri.EscapeDataString(title)}&language=en-US", TimeSpan.FromMinutes(10));
    var hit = search.Results.FirstOrDefault();
    if (hit is null)
    {
        Console.WriteLine($"No movie found for \"{title}\".");
        return 0;
    }

    var movie = await client.GetAsync<Movie>(
        $"movie/{hit.Id}?language=en-US&append_to_response=ratings", TimeSpan.FromHours(6));
    Print(movie);
    return 0;
}
catch (AmodbException ex)
{
    Console.Error.WriteLine(ex.Message);
    return 1;
}
catch (HttpRequestException ex)
{
    Console.Error.WriteLine($"Network error: {ex.Message}");
    return 1;
}
catch (TaskCanceledException)
{
    Console.Error.WriteLine("The request timed out after 10 seconds.");
    return 1;
}

static void Print(Movie m)
{
    var year = m.ReleaseDate is { Length: >= 4 } d ? d[..4] : "n/a";
    Console.WriteLine($"{m.Title} ({year}), {m.Runtime?.ToString() ?? "?"} min");
    if (!string.IsNullOrEmpty(m.Tagline)) Console.WriteLine(m.Tagline);
    Console.WriteLine($"IMDb: {Describe(m.Ratings?.Imdb)}");
    Console.WriteLine($"TMDB: {Describe(m.Ratings?.Tmdb)}");
    if (m.PosterPath is not null)
        Console.WriteLine($"Poster: https://img.amodb.dev/t/p/w342{m.PosterPath}");
}

static string Describe(Score? s) =>
    s is null ? "not known" : $"{s.Value:0.0}" + (s.Votes is { } v ? $" ({v:N0} votes)" : "");

record SearchResponse(List<SearchHit> Results);
record SearchHit(int Id, string Title, string? ReleaseDate);
record Movie(int Id, string Title, string? Tagline, string? ReleaseDate, int? Runtime,
    string? PosterPath, double VoteAverage, RatingSet? Ratings);
record RatingSet(Score? Imdb, Score? Kinopoisk, Score? Metacritic, Score? RottenTomatoes, Score? Tmdb);
record Score(double Value, int? Votes);

class AmodbException : Exception
{
    public AmodbException(string message) : base(message) { }
}

class FileCache
{
    readonly string dir = Path.Combine(Path.GetTempPath(), "amodb-cache");

    string PathFor(string key) =>
        Path.Combine(dir, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key))) + ".json");

    public string? Get(string key, TimeSpan ttl)
    {
        var file = PathFor(key);
        return File.Exists(file) && DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < ttl
            ? File.ReadAllText(file)
            : null;
    }

    public void Set(string key, string json)
    {
        Directory.CreateDirectory(dir);
        File.WriteAllText(PathFor(key), json);
    }
}

class AmodbClient(HttpClient http, FileCache cache)
{
    static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };

    public async Task<T> GetAsync<T>(string path, TimeSpan ttl)
    {
        var cached = cache.Get(path, ttl);
        if (cached is not null) return JsonSerializer.Deserialize<T>(cached, Options)!;

        for (var attempt = 0; ; attempt++)
        {
            using var response = await http.GetAsync(path);
            if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < 2)
            {
                var seconds = Math.Min(response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5, 30);
                await Task.Delay(TimeSpan.FromSeconds(seconds));
                continue;
            }
            if (response.StatusCode == HttpStatusCode.Unauthorized)
                throw new AmodbException("The API key was rejected (401). Check AMODB_KEY.");
            if (!response.IsSuccessStatusCode)
                throw new AmodbException($"amodb returned status {(int)response.StatusCode}.");

            var body = await response.Content.ReadAsStringAsync();
            cache.Set(path, body);
            return JsonSerializer.Deserialize<T>(body, Options)!;
        }
    }
}
```

传入片名运行它。下面是第一条搜索结果（电影 550）的输出。投票数会随时间增长，所以你的结果会略有不同。

```bash
dotnet run -- "fight club"
# Fight Club (1999), 139 min
# Mischief. Mayhem. Soap.
# IMDb: 8.8 (2,669,989 votes)
# TMDB: 8.4 (33,039 votes)
# Poster: https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg
```

TMDB 那一行显示 8.4，因为代码只打印 `ratings.tmdb.value` 中 8.437 的一位小数。海报主机 `img.amodb.dev` 无需密钥，所以你可以把海报 URL 直接交给浏览器或图片控件。尺寸列表见[图片](https://amodb.dev/zh/images-api/)。

## 下一步

- 在[搜索](https://amodb.dev/zh/search/)中按年份或分页搜索，并用[发现](https://amodb.dev/zh/discover/)筛选列表。
- 用[评分](https://amodb.dev/zh/ratings/)显示所有评分及其 null 规则。
- 把控制台代码迁移到使用 `AddHttpClient` 和 `IMemoryCache` 的 ASP.NET Core 服务。
- 在 [Java](https://amodb.dev/zh/java-movie-api/) 或 [Go](https://amodb.dev/zh/go-movie-api/) 中比较同一个任务。

## 常见问题

### 用 C# 调用 API 需要 NuGet 包吗？

不需要。HttpClient 和 System.Text.Json 随 .NET 8 一起提供，所以本教程不添加任何包。把密钥放在 Authorization Bearer 请求头中发送，或者按你的喜好作为 api_key 查询参数传入。

### 如何在 .NET 中读取 Retry-After 响应头？

使用 response.Headers.RetryAfter。当服务器发送秒数时，它的 Delta 属性是一个 TimeSpan。等待这么长时间（上限 30 秒），最多重试两次，之后再报告 429。

### 应该为每个请求创建一个 HttpClient 吗？

不应该。创建一个实例并复用，因为每个新实例都会打开新的连接。在 ASP.NET Core 中用 AddHttpClient 注册类型化客户端并注入。控制台应用可以在整个运行期间保留单个实例。

### 能复用为 TMDB v3 编写的 C# 模型吗？

能。对于 amodb 提供的接口，路径、参数和 JSON 字段都与 TMDB v3 一致，所以你已有的模型可以原样反序列化。把基础地址指向 https://api.amodb.dev/3/，并使用你的 amodb 密钥。

### 为什么评分显示为未知？

当 amodb 没有该作品的评分时，对应评分来源为 null。请把 null 当作未知，绝不要当作零，并且只打印存在的来源。新字段可能会随时间出现，所以请忽略你不认识的字段。

## 相关页面

- [Java 电影 API 教程](https://amodb.dev/zh/java-movie-api/): 用 HttpClient 和 Jackson 构建 Java 剧集指南：获取一季、按 Retry-After 重试 429、缓存到文件。需 Java 17。
- [搜索 API](https://amodb.dev/zh/search/): 路径和 JSON 与 TMDB v3 相同的搜索 API。按文本查找电影、电视剧、人物、系列合集、公司和关键词，支持年份筛选和分页。
- [电影详情 API](https://amodb.dev/zh/movies/): 路径和 JSON 与 TMDB v3 相同的电影详情 API：按 ID 取电影，用 append_to_response 加入演职员和评分，支持 14 种语言。
- [电影与电视剧评分](https://amodb.dev/zh/ratings/): 一次调用返回作品的 IMDb、Kinopoisk、Metacritic、Rotten Tomatoes 和 TMDB 评分，来源未知时为 null。
- [认证](https://amodb.dev/zh/authentication/): 如何发送 amodb 密钥：查询中的 api_key、Bearer 请求头，或 OMDb 的 apikey。含密钥格式、保管和 401 错误。
- [速率限制](https://amodb.dev/zh/rate-limits/): amodb 按滑动 60 秒窗口内每分钟请求数限制每个密钥：按套餐为 20、40 或 120。读取速率限制响应头，并用 Retry-After 处理 429。

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