# Poradnik API filmów w C# dla .NET 8

> Ten poradnik buduje aplikację konsolową .NET 8, która przyjmuje tytuł filmu, wyszukuje go w amodb, pobiera najlepsze dopasowanie wraz z ocenami i wypisuje krótką kartę. Używa tylko HttpClient i System.Text.Json, czyta klucz z AMODB_KEY, ponawia żądanie po 429 i zapisuje odpowiedzi w plikach. Konfiguracja zajmuje około 15 minut.

## Co zbudujesz

Zbudujesz `MovieLookup`, aplikację konsolową, która przyjmuje tytuł z wiersza poleceń, wyszukuje go w amodb, pobiera najlepsze dopasowanie wraz z ocenami i wypisuje krótką kartę z linkiem do plakatu. Potrzebujesz .NET 8 SDK, klucza amodb i około 15 minut. Program używa tylko `HttpClient` i `System.Text.Json`, więc nie instalujesz żadnych pakietów NuGet.

## Krok 1: Zdobądź klucz API

Wybierz plan na stronie [cennik](https://amodb.dev/pl/pricing/). Plany różnią się tylko szybkością, a to 20, 40 lub 120 żądań na minutę. Utwórz klucz w panelu konta, to ciąg 32 małych znaków szesnastkowych. Wyeksportuj go i sprawdź jednym wywołaniem:

```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` oznacza, że klucz działa. `401` oznacza, że klucz jest błędny lub go brakuje. W PowerShell ustaw zmienną przez `$env:AMODB_KEY = "YOUR_API_KEY"`. Strona [uwierzytelnianie](https://amodb.dev/pl/authentication/) opisuje parametr zapytania `api_key` jako alternatywę dla nagłówka.

## Krok 2: Utwórz projekt

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

Szablon .NET 8 włącza niejawne using i nullable reference types. To już importuje `System`, `System.IO`, `System.Linq` i `System.Net.Http`. Pozostałe linie `using` dodajesz w pliku.

## Krok 3: Wyślij pierwsze żądanie

Utwórz jeden `HttpClient` z adresem bazowym, 10-sekundowym timeoutem i nagłówkiem Bearer. Zakończ adres bazowy ukośnikiem i pisz ścieżki względne bez ukośnika na początku. Ukośnik na początku zastąpiłby część `/3` ścieżki bazowej.

```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);
```

## Krok 4: Zamień JSON na rekordy

Odpowiedź używa nazw w snake case, takich jak `release_date`. Ustaw `JsonNamingPolicy.SnakeCaseLower`, nowe w .NET 8, i pisz składowe w PascalCase. Pola, których nie zadeklarujesz, są ignorowane.

```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);
```

Obiekt `ratings` pojawia się, bo wywołanie szczegółów dodaje `append_to_response=ratings`. Każde z jego pięciu źródeł to obiekt z `value` i `votes` albo `null`, gdy brakuje wyniku. `votes` też może być `null`: rzeczywista odpowiedź dla Fight Club nie ma liczby głosów dla Kinopoisk, Metacritic ani Rotten Tomatoes. Wszystkie pozostałe pola wymienia strona [filmy](https://amodb.dev/pl/movies/).

## Krok 5: Obsłuż błędy i limity żądań

Obejmij wywołanie pętlą, która reaguje na kod statusu.

| Status      | Znaczenie                               | Co robi aplikacja                                                                         |
| ----------- | --------------------------------------- | ----------------------------------------------------------------------------------------- |
| 401         | Brak klucza, jest błędny lub zawieszony | Wypisuje komunikat i kończy, ponowienie nie pomoże.                                       |
| 402         | Okres planu dobiegł końca               | Wypisuje komunikat i kończy. Odnów go na stronie [cennik](https://amodb.dev/pl/pricing/). |
| 404         | Brak rekordu dla tego ID                | Zgłasza to i kończy.                                                                      |
| 429         | Przekroczono żądania na minutę          | Czeka `Retry-After` sekund, maksymalnie 30, i ponawia najwyżej dwa razy.                  |
| 500 lub 504 | Awaria po stronie serwera               | Zgłasza status, uruchom polecenie ponownie później.                                       |

```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
}
```

Błędy sieci pojawiają się jako `HttpRequestException`, a timeout jako `TaskCanceledException`. Przechwyć oba na najwyższym poziomie. Każda odpowiedź z kluczem niesie też `X-RateLimit-Remaining`, więc długie zadanie może zwolnić, zanim dojdzie do 429. Pełne listy znajdziesz na stronach [limity żądań](https://amodb.dev/pl/rate-limits/) i [błędy](https://amodb.dev/pl/errors/).

## Krok 6: Zapisuj odpowiedzi na dysku

Sprawdzenie limitu działa przed cache na edge, więc odpowiedź podana z edge nadal liczy się do twoich żądań na minutę. Cache we własnym programie to jedyny sposób na oszczędzanie żądań. Edge trzyma szczegóły przez 6 godzin, a wyniki wyszukiwania przez 10 minut, więc aplikacja używa tych samych czasów życia. Plik w folderze temp jest świeży, gdy jego czas zapisu mieści się w 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;
    }
}
```

## Krok 7: Złóż całość

To kompletny `Program.cs`. Dodaje do cache metodę `Set` oraz klasę `AmodbClient`, która łączy cache, pętlę ponowień i opcje 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)!;
        }
    }
}
```

Uruchom go z tytułem. To wynik dla pierwszego trafienia wyszukiwania, filmu 550. Liczby głosów rosną z czasem, więc twoje będą się trochę różnić.

```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
```

Linia TMDB pokazuje 8.4, bo kod wypisuje jedno miejsce po przecinku z 8.437 w `ratings.tmdb.value`. Host plakatów `img.amodb.dev` nie wymaga klucza, więc możesz przekazać URL plakatu prosto do przeglądarki lub kontrolki obrazu. Listę rozmiarów znajdziesz w [obrazy](https://amodb.dev/pl/images-api/).

## Kolejne kroki

- Szukaj z rokiem lub paginacją na stronie [wyszukiwanie](https://amodb.dev/pl/search/) i filtruj listy przez [discover](https://amodb.dev/pl/discover/).
- Pokaż każdy wynik i jego reguły null na stronie [oceny](https://amodb.dev/pl/ratings/).
- Przenieś kod konsolowy do usługi ASP.NET Core z `AddHttpClient` i `IMemoryCache`.
- Porównaj to samo zadanie w [Java](https://amodb.dev/pl/java-movie-api/) lub [Go](https://amodb.dev/pl/go-movie-api/).

## Pytania i odpowiedzi

### Czy potrzebuję pakietu NuGet, by wywołać API z C#?

Nie. HttpClient i System.Text.Json są w .NET 8, więc ten poradnik nie dodaje żadnych pakietów. Wyślij klucz w nagłówku Authorization Bearer albo, jeśli wolisz, przekaż go jako parametr zapytania api_key.

### Jak odczytać nagłówek Retry-After w .NET?

Użyj response.Headers.RetryAfter. Gdy serwer wyśle liczbę sekund, jego właściwość Delta zawiera TimeSpan. Poczekaj tyle, maksymalnie 30 sekund, i ponów najwyżej dwa razy, zanim zgłosisz 429.

### Czy tworzyć jeden HttpClient na każde żądanie?

Nie. Utwórz jedną instancję i używaj jej ponownie, bo każda nowa instancja otwiera świeże połączenia. W ASP.NET Core zarejestruj typowanego klienta przez AddHttpClient i wstrzyknij go. Aplikacja konsolowa może trzymać jedną instancję przez cały przebieg.

### Czy mogę użyć modeli C# napisanych pod TMDB v3?

Tak. Dla endpointów, które oferuje amodb, ścieżki, parametry i pola JSON zgadzają się z TMDB v3, więc modele, które już masz, deserializują się bez zmian. Wskaż adres bazowy https://api.amodb.dev/3/ i użyj klucza amodb.

### Dlaczego karta wypisuje not known zamiast oceny?

Źródło oceny ma wartość null, gdy amodb nie ma wyniku dla tego tytułu. Traktuj null jako nieznane, nigdy jako zero, i wypisuj tylko źródła, które są obecne. Z czasem mogą pojawić się nowe pola, więc ignoruj pola, których nie znasz.

## Powiązane strony

- [Poradnik API filmów w Javie](https://amodb.dev/pl/java-movie-api/): Zbuduj w Javie przewodnik po odcinkach z JDK HttpClient i rekordami Jackson: pobierz sezon, respektuj Retry-After po 429, zapisuj w plikach. Java 17+.
- [API wyszukiwania](https://amodb.dev/pl/search/): API wyszukiwania z tymi samymi ścieżkami i JSON co TMDB v3. Znajdź filmy, seriale, osoby, kolekcje, firmy i słowa kluczowe po tekście, z filtrem roku.
- [API szczegółów filmu](https://amodb.dev/pl/movies/): API szczegółów filmu z tymi samymi ścieżkami i JSON co TMDB v3. Pobierz film po ID, dołącz obsadę, oceny i nastroje przez append_to_response, 14 języków.
- [Oceny filmów i seriali](https://amodb.dev/pl/ratings/): API ocen filmów zwraca wyniki z IMDb, Kinopoisk, Metacritic, Rotten Tomatoes i TMDB dla tytułu jednym wywołaniem, z null dla każdego nieznanego źródła.
- [Uwierzytelnianie](https://amodb.dev/pl/authentication/): Jak wysłać klucz amodb: api_key w zapytaniu, nagłówek Authorization Bearer albo apikey dla wywołań OMDb. Format klucza, przechowywanie, rotacja i błędy 401.
- [Limity żądań](https://amodb.dev/pl/rate-limits/): amodb limituje klucz liczbą żądań na minutę w przesuwanym oknie 60 sekund: 20, 40 lub 120 zależnie od planu. Czytaj nagłówki limitu i obsłuż 429 z Retry-After.

Zaktualizowano: 2026-10-18. Source: https://amodb.dev/pl/csharp-movie-api/
