# מדריך API לסרטים ב-C# עבור .NET 8

> המדריך בונה אפליקציית קונסולה ב-.NET 8 שמקבלת כותרת סרט, מחפשת ב-amodb, טוענת את ההתאמה הטובה ביותר עם הדירוגים שלה ומדפיסה כרטיס קצר. היא משתמשת רק ב-HttpClient וב-System.Text.Json, קוראת את המפתח מ-AMODB_KEY, מנסה שוב ב-429 ושומרת תגובות במטמון בקבצים. ההקמה לוקחת כ-15 דקות.

## מה בונים

בונים את `MovieLookup`, אפליקציית קונסולה שמקבלת כותרת משורת הפקודה, מחפשת ב-amodb, טוענת את ההתאמה הטובה ביותר עם הדירוגים שלה ומדפיסה כרטיס קצר עם קישור לפוסטר. צריך את ה-SDK של .NET 8, מפתח amodb וכ-15 דקות. התוכנית משתמשת רק ב-`HttpClient` וב-`System.Text.Json`, ולכן אין חבילות NuGet להתקנה.

## שלב 1: קבלת מפתח API

בחרו מסלול בעמוד [תמחור](https://amodb.dev/he/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/he/authentication/) מסביר את פרמטר השאילתה `api_key` כחלופה לכותרת.

## שלב 2: יצירת הפרויקט

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

התבנית של .NET 8 מפעילה using מרומזים וטיפוסי הפניה שאינם מקבלים null. כך כבר מיובאים `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/he/movies/).

## שלב 5: טיפול בשגיאות ובמגבלות קצב

עטפו את הקריאה בלולאה שמגיבה לקוד הסטטוס.

| סטטוס      | משמעות                   | מה האפליקציה עושה                                                       |
| ---------- | ------------------------ | ----------------------------------------------------------------------- |
| 401        | המפתח חסר, שגוי או מושעה | להדפיס הודעה ולעצור, ניסיון חוזר לא יעזור.                              |
| 402        | תקופת המסלול הסתיימה     | להדפיס הודעה ולעצור. לחדש בעמוד [תמחור](https://amodb.dev/he/pricing/). |
| 404        | אין רשומה למזהה הזה      | לדווח ולצאת.                                                            |
| 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/he/rate-limits/) ו[שגיאות](https://amodb.dev/he/errors/).

## שלב 6: שמירת תגובות במטמון בדיסק

בדיקת מגבלת הקצב רצה לפני מטמון הקצה, ולכן תשובה שהוגשה מהקצה עדיין נספרת בבקשות שלכם לדקה. מטמון בתוכנית שלכם הוא הדרך היחידה לחסוך בקשות. הקצה שומר פרטים 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` לא דורש מפתח, ולכן אפשר להעביר את כתובת הפוסטר ישר לדפדפן או לפקד תמונה. רשימת הגדלים בעמוד [תמונות](https://amodb.dev/he/images-api/).

## הצעדים הבאים

- חפשו עם שנה או עם דפדוף בעמוד [חיפוש](https://amodb.dev/he/search/), וסננו רשימות עם [גילוי](https://amodb.dev/he/discover/).
- הציגו את כל הציונים ואת כללי ה-null שלהם עם [דירוגים](https://amodb.dev/he/ratings/).
- העבירו את קוד הקונסולה לשירות ASP.NET Core עם `AddHttpClient` ו-`IMemoryCache`.
- השוו את אותה משימה ב-[Java](https://amodb.dev/he/java-movie-api/) או ב-[Go](https://amodb.dev/he/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 שלכם.

### למה הכרטיס מדפיס לא ידוע עבור דירוג?

מקור דירוג הוא null כש-amodb אין ציון לכותר הזה. התייחסו ל-null כאל לא ידוע ולעולם לא כאל אפס, והדפיסו רק את המקורות הקיימים. שדות חדשים עשויים להופיע עם הזמן, ולכן התעלמו משדות שאינכם מכירים.

## עמודים קשורים

- [מדריך API לסרטים ב-Java](https://amodb.dev/he/java-movie-api/): בנו מדריך פרקים ב-Java עם HttpClient של ה-JDK ורשומות Jackson: שלפו עונה, כבדו את Retry-After ב-429 ושמרו תגובות בקבצים. Java 17 ומעלה ו-Maven 3.9.
- [API חיפוש](https://amodb.dev/he/search/): API חיפוש עם אותם נתיבים ו-JSON כמו TMDB v3. מצאו סרטים, סדרות, אנשים, אוספים, חברות ומילות מפתח לפי טקסט, עם סינון לפי שנה ועימוד של 20 תוצאות בעמוד.
- [API פרטי סרט](https://amodb.dev/he/movies/): API פרטי סרט עם אותם נתיבים ו-JSON כמו TMDB v3. קבלו סרט לפי מזהה, הוסיפו קרדיטים, דירוגים, מצבי רוח ופסקולים עם append_to_response, ב-14 שפות.
- [דירוגי סרטים וסדרות](https://amodb.dev/he/ratings/): API דירוגי סרטים וסדרות שמחזיר ציוני IMDb, Kinopoisk, Metacritic, Rotten Tomatoes ו-TMDB לכותר בקריאה אחת, עם value ו-votes ו-null לכל מקור שאינו ידוע.
- [אימות](https://amodb.dev/he/authentication/): איך שולחים את מפתח amodb: api_key בשאילתה, כותרת Authorization Bearer או apikey בקריאות OMDb. פורמט המפתח, שמירה, החלפה ושגיאות 401 שכדאי להכיר.
- [מגבלות קצב](https://amodb.dev/he/rate-limits/): amodb מגביל כל מפתח לפי בקשות לדקה בחלון נע של 60 שניות: 20, 40 או 120 בהתאם למסלול שלכם. קראו את כותרות מגבלת הקצב וטפלו ב-429 עם Retry-After.

עודכן: 2026-10-18. Source: https://amodb.dev/he/csharp-movie-api/
