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

В этом руководстве вы построите консольное приложение .NET 8: оно берет название фильма, ищет его в amodb, загружает лучшее совпадение с рейтингами и печатает короткую карточку. Используются только HttpClient и System.Text.Json, ключ читается из AMODB_KEY, при 429 выполняется повтор, ответы кэшируются в файлах. Настройка займет около 15 минут.

.cs C# Обновлено Открыть в Markdown

Что вы построите

Вы построите MovieLookup, консольное приложение, которое берет название из командной строки, ищет его в amodb, загружает лучшее совпадение с рейтингами и печатает короткую карточку со ссылкой на постер. Вам понадобятся .NET 8 SDK, ключ amodb и около 15 минут. Программа использует только HttpClient и System.Text.Json, поэтому пакеты NuGet устанавливать не нужно.

Шаг 1: получите ключ API

Выберите тариф на странице цены. Тарифы различаются только скоростью: 20, 40 или 120 запросов в минуту. Создайте ключ в панели аккаунта, это строка из 32 шестнадцатеричных символов в нижнем регистре. Экспортируйте его и проверьте одним вызовом:

Ответ 200 означает, что ключ работает. Ответ 401 означает, что ключ неверный или отсутствует. В PowerShell задайте переменную так: $env:AMODB_KEY = "YOUR_API_KEY". Страница аутентификация описывает параметр запроса api_key как альтернативу заголовку.

Shell
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"

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

Шаблон .NET 8 включает неявные using и ссылочные типы, допускающие null. Благодаря этому уже подключены System, System.IO, System.Linq и System.Net.Http. Остальные строки using вы добавляете в файле.

Shell
dotnet new console -n MovieLookup -f net8.0
cd MovieLookup

Шаг 3: отправьте первый запрос

Создайте один HttpClient с базовым адресом, таймаутом 10 секунд и заголовком Bearer. Завершите базовый адрес косой чертой, а относительные пути пишите без ведущей косой черты. Ведущая косая черта заменила бы часть /3 в базовом пути.

C#
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. Поля, которые вы не объявили, игнорируются.

Объект ratings появляется, потому что вызов сведений добавляет append_to_response=ratings. Каждый из пяти его источников это объект с value и votes или null, если оценки нет. Значение votes тоже может быть null: в настоящем ответе для Fight Club нет числа голосов у Kinopoisk, Metacritic и Rotten Tomatoes. Все остальные поля перечислены на странице фильмы.

C#
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);

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

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

СтатусЗначениеЧто делает приложение
401Ключ отсутствует, неверный или приостановленВывести сообщение и остановиться, повтор не поможет.
402Период тарифа закончилсяВывести сообщение и остановиться. Продлите на странице цены.
404Для этого id нет записиСообщить об этом и выйти.
429Превышено число запросов в минутуЖдать Retry-After секунд, но не больше 30, и повторить не более двух раз.
500 или 504Сбой на стороне сервераСообщить статус и запустить команду позже.

Сетевые сбои проявляются как HttpRequestException, а таймаут как TaskCanceledException. Перехватывайте оба на верхнем уровне. Каждый ответ с ключом содержит также X-RateLimit-Remaining, поэтому долгая задача может замедлиться до того, как упрется в 429. Полные списки есть на страницах лимиты запросов и ошибки.

C#
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
}

Шаг 6: кэшируйте ответы на диске

Проверка лимита выполняется раньше кэша на границе сети, поэтому ответ, отданный с границы сети, все равно идет в счет ваших запросов в минуту. Сэкономить запросы можно только кэшем в собственной программе. Граница сети хранит сведения 6 часов, а результаты поиска 10 минут, поэтому приложение использует те же сроки. Файл во временной папке свеж, если время его записи укладывается в TTL.

C#
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.

C#
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. Число голосов со временем растет, поэтому ваши значения будут немного отличаться.

В строке TMDB стоит 8.4, потому что код печатает один знак после запятой от 8.437 в ratings.tmdb.value. Хост постеров img.amodb.dev не требует ключа, поэтому ссылку на постер можно передать прямо в браузер или элемент изображения. Список размеров смотрите на странице API изображений.

Shell
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

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

  • Искать с годом или постраничной выдачей можно на странице поиск, а фильтровать списки через discover.
  • Показать все оценки и правила для null можно с помощью страницы рейтинги.
  • Перенесите консольный код в сервис ASP.NET Core с AddHttpClient и IMemoryCache.
  • Сравните ту же задачу на Java или Go.