# Посібник з API фільмів на C# для .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/uk/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"`. Параметр запиту `api_key` як альтернативу заголовку описано на сторінці [автентифікація](https://amodb.dev/uk/authentication/).

## Крок 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 у записи

Відповідь використовує імена в 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/uk/movies/).

## Крок 5: обробіть помилки та ліміти запитів

Загорніть виклик у цикл, що реагує на код статусу.

| Статус      | Значення                                      | Що робить застосунок                                                                             |
| ----------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 401         | Ключ відсутній, неправильний або призупинений | Вивести повідомлення й зупинитися, повтор не допоможе.                                           |
| 402         | Період тарифу закінчився                      | Вивести повідомлення й зупинитися. Продовжіть на сторінці [ціни](https://amodb.dev/uk/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/uk/rate-limits/) та [помилки](https://amodb.dev/uk/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/uk/images-api/).

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

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

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