# Падручнік па 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/be/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/be/authentication/) апісвае параметр запыту `api_key` як альтэрнатыву загалоўку.

## Крок 2: Стварыце праект

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

Шаблон .NET 8 уключае неяўныя using і nullable-тыпы спасылак. Гэта ўжо імпартуе `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 у records

Адказ выкарыстоўвае назвы ў snake case, напрыклад `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/be/movies/).

## Крок 5: Апрацоўка памылак і лімітаў

Загарніце выклік у цыкл, які рэагуе на код статусу.

| Статус      | Значэнне                                   | Што робіць праграма                                                                                   |
| ----------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| 401         | Ключ адсутнічае, няправільны або прыпынены | Друкуе паведамленне і спыняецца, паўтор не дапаможа.                                                  |
| 402         | Тэрмін дзеяння плана скончыўся             | Друкуе паведамленне і спыняецца. Прадоўжыце доступ на старонцы [цэны](https://amodb.dev/be/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/be/rate-limits/) і [памылкі](https://amodb.dev/be/errors/).

## Крок 6: Кэшуйце адказы на дыску

Праверка ліміту запытаў выконваецца да edge-кэшу, таму адказ, выдадзены з edge, усё роўна ўлічваецца ў вашых запытах у хвіліну. Кэш у вашай праграме з’яўляецца адзіным спосабам зэканоміць запыты. Edge трымае дэталі 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` не патрабуе ключа, таму URL постэра можна перадаць прама ў браўзер або элемент выявы. Спіс памераў гл. на старонцы [выявы](https://amodb.dev/be/images-api/).

## Наступныя крокі

- Шукайце з годам або пагінацыяй у [пошуку](https://amodb.dev/be/search/), а спісы фільтруйце праз [пошук па фільтрах](https://amodb.dev/be/discover/).
- Паказвайце кожную ацэнку і правілы для null праз [рэйтынгі](https://amodb.dev/be/ratings/).
- Перанясіце кансольны код у сэрвіс ASP.NET Core з `AddHttpClient` і `IMemoryCache`.
- Параўнайце тую ж задачу на [Java](https://amodb.dev/be/java-movie-api/) або [Go](https://amodb.dev/be/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 і API фільмаў](https://amodb.dev/be/java-movie-api/): Створыце на Java даведнік па эпізодах з JDK HttpClient і Jackson: загрузіце сезон, улічыце Retry-After пры 429 і кэшуйце адказы ў файлах. Java 17 або новей.
- [API пошуку](https://amodb.dev/be/search/): API пошуку з шляхамі і JSON як у TMDB v3. Фільмы, серыялы, персоны, калекцыі, кампаніі і ключавыя словы па тэксце, з фільтрамі па годзе і старонкамі.
- [API дэталяў фільма](https://amodb.dev/be/movies/): API дэталяў фільма з тымі ж шляхамі і JSON, што ў TMDB v3. Адзін фільм па id, а акцёры, рэйтынгі і настрой праз append_to_response, на 14 мовах.
- [Рэйтынгі фільмаў і серыялаў](https://amodb.dev/be/ratings/): API рэйтынгаў фільмаў вяртае ацэнкі IMDb, Kinopoisk, Metacritic, Rotten Tomatoes і TMDB для назвы адным выклікам, з null для крыніцы, якая невядомая.
- [Аўтэнтыфікацыя](https://amodb.dev/be/authentication/): Як перадаваць ключ amodb: api_key у запыце, загаловак Authorization Bearer або apikey для выклікаў OMDb. Фармат ключа, захоўванне, замена і памылкі 401.
- [Ліміты запытаў](https://amodb.dev/be/rate-limits/): amodb абмяжоўвае ключ па запытах у хвіліну ў слізгальным акне 60 секунд: 20, 40 або 120 па плане. Чытайце загалоўкі лімітаў і апрацоўвайце 429 праз Retry-After.

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/csharp-movie-api/
