# Tutorial API film in C# per .NET 8

> Questo tutorial costruisce un'app console .NET 8 che prende il titolo di un film, lo cerca su amodb, carica il risultato migliore con le sue valutazioni e stampa una scheda breve. Usa solo HttpClient e System.Text.Json, legge la chiave da AMODB_KEY, ritenta su 429 e mette in cache le risposte in file. La configurazione richiede circa 15 minuti.

## Cosa costruisci

Costruisci `MovieLookup`, un'app console che prende un titolo dalla riga di comando, lo cerca su amodb, carica il risultato migliore con le sue valutazioni e stampa una breve scheda con il link della locandina. Ti servono l'SDK .NET 8, una chiave amodb e circa 15 minuti. Il programma usa solo `HttpClient` e `System.Text.Json`, quindi non c'è alcun pacchetto NuGet da installare.

## Passaggio 1: ottieni una chiave API

Scegli un piano nella pagina dei [prezzi](https://amodb.dev/it/pricing/). I piani differiscono solo per la frequenza, a 20, 40 o 120 richieste al minuto. Crea una chiave nella dashboard del tuo account: una stringa di 32 caratteri esadecimali minuscoli. Esportala e provala con una chiamata:

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

Un `200` significa che la chiave funziona. Un `401` significa che la chiave è sbagliata o mancante. In PowerShell imposta la variabile con `$env:AMODB_KEY = "YOUR_API_KEY"`. La pagina [autenticazione](https://amodb.dev/it/authentication/) descrive il parametro di query `api_key` come alternativa all'header.

## Passaggio 2: crea il progetto

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

Il template di .NET 8 attiva gli using impliciti e i tipi riferimento non nullable. Questo importa già `System`, `System.IO`, `System.Linq` e `System.Net.Http`. Aggiungi tu le altre righe `using` nel file.

## Passaggio 3: invia la prima richiesta

Crea un solo `HttpClient` con un indirizzo di base, un timeout di 10 secondi e l'header Bearer. Termina l'indirizzo di base con una barra e scrivi i percorsi relativi senza barra iniziale. Una barra iniziale sostituirebbe la parte `/3` del percorso di base.

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

## Passaggio 4: analizza il JSON in record

La risposta usa nomi in snake case come `release_date`. Imposta `JsonNamingPolicy.SnakeCaseLower`, novità di .NET 8, e scrivi membri in PascalCase. I campi che non dichiari vengono ignorati.

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

L'oggetto `ratings` compare perché la chiamata dei dettagli aggiunge `append_to_response=ratings`. Ognuna delle sue cinque fonti è un oggetto con `value` e `votes`, oppure `null` quando il punteggio manca. Anche `votes` può essere `null`: la risposta reale per Fight Club non ha il numero di voti per Kinopoisk, Metacritic e Rotten Tomatoes. La pagina [film](https://amodb.dev/it/movies/) elenca tutti gli altri campi.

## Passaggio 5: gestisci errori e limiti di richieste

Racchiudi la chiamata in un ciclo che reagisce al codice di stato.

| Stato     | Significato                          | Cosa fa l'app                                                                                     |
| --------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
| 401       | Chiave mancante, sbagliata o sospesa | Stampa un messaggio e si ferma, un nuovo tentativo non può aiutare.                               |
| 402       | Il periodo del piano è terminato     | Stampa un messaggio e si ferma. Rinnova nella pagina dei [prezzi](https://amodb.dev/it/pricing/). |
| 404       | Nessun record per quell'ID           | Lo segnala ed esce.                                                                               |
| 429       | Oltre le tue richieste al minuto     | Attende i secondi di `Retry-After`, con un massimo di 30, e ritenta al massimo due volte.         |
| 500 o 504 | Errore lato server                   | Segnala lo stato: il comando va rieseguito più tardi.                                             |

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

I guasti di rete emergono come `HttpRequestException`, e un timeout emerge come `TaskCanceledException`. Intercettali entrambi al livello più alto. Ogni risposta con chiave porta anche `X-RateLimit-Remaining`, così un job lungo può rallentare prima di arrivare a un 429. Vedi [limiti di richieste](https://amodb.dev/it/rate-limits/) ed [errori](https://amodb.dev/it/errors/) per gli elenchi completi.

## Passaggio 6: metti le risposte in cache su disco

Il controllo del limite di richieste viene prima della cache edge, quindi una risposta servita dall'edge conta comunque nelle tue richieste al minuto. Una cache nel tuo programma è l'unico modo per risparmiare richieste. L'edge tiene i dettagli per 6 ore e i risultati di ricerca per 10 minuti, e l'app usa gli stessi tempi. Un file nella cartella temporanea è fresco quando la sua data di scrittura rientra nel 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;
    }
}
```

## Passaggio 7: metti tutto insieme

Questo è il `Program.cs` completo. Aggiunge il metodo `Set` alla cache e la classe `AmodbClient` che combina la cache, il ciclo di nuovi tentativi e le opzioni 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)!;
        }
    }
}
```

Eseguilo con un titolo. Questo è l'output per il primo risultato di ricerca, il film 550. I conteggi dei voti crescono nel tempo, quindi i tuoi saranno un po' diversi.

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

La riga TMDB mostra 8,4 perché il codice stampa un decimale dell'8.437 di `ratings.tmdb.value`. L'host delle locandine `img.amodb.dev` non richiede chiave, quindi puoi passare l'URL della locandina direttamente a un browser o a un controllo immagine. Vedi [immagini](https://amodb.dev/it/images-api/) per l'elenco delle dimensioni.

## Prossimi passi

- Cerca con un anno o con la paginazione in [ricerca](https://amodb.dev/it/search/), e filtra le liste con [discover](https://amodb.dev/it/discover/).
- Mostra tutti i punteggi e le loro regole sul null con [valutazioni](https://amodb.dev/it/ratings/).
- Sposta il codice della console in un servizio ASP.NET Core con `AddHttpClient` e `IMemoryCache`.
- Confronta lo stesso compito in [Java](https://amodb.dev/it/java-movie-api/) o in [Go](https://amodb.dev/it/go-movie-api/).

## Domande e risposte

### Mi serve un pacchetto NuGet per chiamare l'API da C#?

No. HttpClient e System.Text.Json sono inclusi in .NET 8, quindi questo tutorial non aggiunge pacchetti. Invia la chiave in un header Authorization Bearer, oppure passala nel parametro di query api_key se preferisci.

### Come leggo l'header Retry-After in .NET?

Usa response.Headers.RetryAfter. Quando il server invia un numero di secondi, la sua proprietà Delta contiene un TimeSpan. Attendi quel tempo, con un massimo di 30 secondi, e ritenta al massimo due volte prima di segnalare il 429.

### Devo creare un HttpClient per ogni richiesta?

No. Crea una sola istanza e riusala, perché ogni nuova istanza apre connessioni nuove. In ASP.NET Core registra un client tipizzato con AddHttpClient e iniettalo. Un'app console può tenere una sola istanza per tutta l'esecuzione.

### Posso riusare i modelli C# scritti per TMDB v3?

Sì. Per gli endpoint offerti da amodb, percorsi, parametri e campi JSON corrispondono a TMDB v3, quindi i modelli che hai già scritto si deserializzano senza modifiche. Punta l'indirizzo di base a https://api.amodb.dev/3/ e usa la tua chiave amodb.

### Perché la scheda stampa «non noto» per una valutazione?

Una fonte di valutazione è null quando amodb non ha un punteggio per quel titolo. Tratta null come sconosciuto, mai come zero, e stampa solo le fonti presenti. Col tempo possono comparire nuovi campi, quindi ignora quelli che non conosci.

## Pagine correlate

- [Tutorial API film in Java](https://amodb.dev/it/java-movie-api/): Guida agli episodi in Java con l'HttpClient del JDK e i record Jackson: leggi una stagione, gestisci Retry-After su 429, cache su file. Java 17 o successivo.
- [API di ricerca](https://amodb.dev/it/search/): API di ricerca con percorsi e JSON di TMDB v3. Trova film, serie, persone, collezioni, società e parole chiave per testo, con filtri anno e paginazione.
- [API dei dettagli del film](https://amodb.dev/it/movies/): API dettagli film con gli stessi percorsi e JSON di TMDB v3. Ottieni un film per ID, aggiungi crediti, valutazioni e mood con append_to_response, in 14 lingue.
- [Valutazioni di film e serie TV](https://amodb.dev/it/ratings/): API valutazioni film: punteggi IMDb, Kinopoisk, Metacritic, Rotten Tomatoes e TMDB di un titolo in una chiamata, con null per le fonti non note.
- [Autenticazione](https://amodb.dev/it/authentication/): Come inviare la chiave amodb: api_key nella query, un header Authorization Bearer o apikey per le chiamate OMDb. Formato, conservazione, rotazione, errori 401.
- [Limiti di richieste](https://amodb.dev/it/rate-limits/): amodb limita ogni chiave per richieste al minuto in una finestra scorrevole di 60 secondi: 20, 40 o 120 in base al piano. Leggi gli header, gestisci il 429.

Aggiornato: 2026-10-18. Source: https://amodb.dev/it/csharp-movie-api/
