# Руководство по C# API фильмов для .NET 8

> В этом руководстве вы построите консольное приложение .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/ru/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/ru/authentication/) описывает параметр запроса `api_key` как альтернативу заголовку.

## Шаг 2: создайте проект

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

Шаблон .NET 8 включает неявные using и ссылочные типы, допускающие null. Благодаря этому уже подключены `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 в записи

В ответе имена написаны в змеином регистре, например `release_date`. Задайте `JsonNamingPolicy.SnakeCaseLower`, который появился в .NET 8, и пишите члены в 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`: в настоящем ответе для Fight Club нет числа голосов у Kinopoisk, Metacritic и Rotten Tomatoes. Все остальные поля перечислены на странице [фильмы](https://amodb.dev/ru/movies/).

## Шаг 5: обработайте ошибки и лимиты

Оберните вызов в цикл, который реагирует на код статуса.

| Статус      | Значение                                     | Что делает приложение                                                                         |
| ----------- | -------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 401         | Ключ отсутствует, неверный или приостановлен | Вывести сообщение и остановиться, повтор не поможет.                                          |
| 402         | Период тарифа закончился                     | Вывести сообщение и остановиться. Продлите на странице [цены](https://amodb.dev/ru/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/ru/rate-limits/) и [ошибки](https://amodb.dev/ru/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, потому что код печатает один знак после запятой от 8.437 в `ratings.tmdb.value`. Хост постеров `img.amodb.dev` не требует ключа, поэтому ссылку на постер можно передать прямо в браузер или элемент изображения. Список размеров смотрите на странице [API изображений](https://amodb.dev/ru/images-api/).

## Дальнейшие шаги

- Искать с годом или постраничной выдачей можно на странице [поиск](https://amodb.dev/ru/search/), а фильтровать списки через [discover](https://amodb.dev/ru/discover/).
- Показать все оценки и правила для null можно с помощью страницы [рейтинги](https://amodb.dev/ru/ratings/).
- Перенесите консольный код в сервис ASP.NET Core с `AddHttpClient` и `IMemoryCache`.
- Сравните ту же задачу на [Java](https://amodb.dev/ru/java-movie-api/) или [Go](https://amodb.dev/ru/go-movie-api/).

## Вопросы и ответы

### Нужен ли пакет NuGet для вызова API из C#?

Нет. HttpClient и System.Text.Json входят в .NET 8, поэтому это руководство не добавляет пакетов. Отправляйте ключ в заголовке Authorization Bearer или передавайте его параметром запроса api_key, если так удобнее.

### Как прочитать заголовок Retry-After в .NET?

Используйте response.Headers.RetryAfter. Если сервер прислал число секунд, его свойство Delta хранит TimeSpan. Подождите это время, но не больше 30 секунд, и повторите не более двух раз, прежде чем сообщить о 429.

### Нужно ли создавать новый HttpClient для каждого запроса?

Нет. Создайте один экземпляр и используйте его повторно, потому что каждый новый экземпляр открывает свежие соединения. В ASP.NET Core зарегистрируйте типизированный клиент через AddHttpClient и внедряйте его. Консольное приложение может держать один экземпляр на все время работы.

### Можно ли использовать модели C#, написанные для TMDB v3?

Да. Для эндпоинтов, которые предлагает amodb, пути, параметры и поля JSON совпадают с TMDB v3, поэтому уже написанные модели десериализуются без изменений. Укажите базовый адрес https://api.amodb.dev/3/ и используйте ключ amodb.

### Почему в карточке вместо рейтинга выводится not known?

Источник рейтинга равен null, если у amodb нет оценки для этого названия. Считайте null неизвестным значением, а не нулем, и печатайте только те источники, которые есть. Со временем могут появляться новые поля, поэтому игнорируйте незнакомые.

## Связанные страницы

- [Руководство по Java movie API](https://amodb.dev/ru/java-movie-api/): Создайте гид по сериям на Java с JDK HttpClient и Jackson: загрузка сезона, пауза по Retry-After при 429 и кэш ответов в файлах. Нужна Java 17 или новее.
- [API поиска](https://amodb.dev/ru/search/): API поиска с путями и JSON как в TMDB v3. Находите фильмы, сериалы, людей, коллекции, компании и ключевые слова по тексту, с фильтром по году и пагинацией.
- [API деталей фильма](https://amodb.dev/ru/movies/): API деталей фильма с теми же путями и JSON, что в TMDB v3. Получите фильм по ID, добавьте актеров, рейтинги и настроения через append_to_response, на 14 языках.
- [Рейтинги фильмов и сериалов](https://amodb.dev/ru/ratings/): API рейтингов фильмов: оценки IMDb, Kinopoisk, Metacritic, Rotten Tomatoes и TMDB для тайтла одним запросом, с null для источника, о котором нет данных.
- [Аутентификация](https://amodb.dev/ru/authentication/): Как передать ключ amodb: api_key в запросе, заголовок Authorization Bearer или apikey для вызовов OMDb. Формат ключа, хранение, ротация и ошибки 401.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.

Обновлено: 2026-10-18. Source: https://amodb.dev/ru/csharp-movie-api/
