# Урок за 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/bg/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/bg/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` с базов адрес, timeout от 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/bg/movies/) изброява всички други полета.

## Стъпка 5: Обработка на грешки и лимити

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

| Статус      | Значение                            | Какво прави приложението                                                                        |
| ----------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- |
| 401         | Ключът липсва, е грешен или е спрян | Отпечатва съобщение и спира, повторението не помага.                                            |
| 402         | Периодът на плана е изтекъл         | Отпечатва съобщение и спира. Подновете от страницата с [цените](https://amodb.dev/bg/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/bg/rate-limits/) и [грешки](https://amodb.dev/bg/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/bg/images-api/).

## Следващи стъпки

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

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