# Návod na filmové API v Go

> Vytvořte nástroj pro příkazový řádek v Go, který vypíše nejlépe hodnocené filmy zvoleného žánru a roku vydání. Dekóduje JSON do typovaných struktur, po 429 opakuje pokus po prodlevě z Retry-After a ukládá odpovědi na disk. Používá jen standardní knihovnu, vyžaduje Go 1.22 nebo novější a zabere asi 20 minut.

Vytvoříte `topfilms`, nástroj pro příkazový řádek v Go. Spusťte `go run . -genre 878 -year 2010 -n 10` a vypíše deset nejlépe hodnocených sci-fi filmů z roku 2010 se skóre a počtem hlasů. Používá typované struktury, jen standardní knihovnu a Go 1.22 nebo novější. Počítejte s 20 minutami.

## Krok 1: Získejte API klíč

Začněte na stránce [ceník](https://amodb.dev/cs/pricing/), kde zvolíte předplacené období a jeden z tarifů s 20, 40 nebo 120 požadavky za minutu. Pak vytvořte klíč v panelu účtu a exportujte ho:

```bash
export AMODB_KEY="YOUR_API_KEY"
```

Program ho čte pomocí `os.Getenv("AMODB_KEY")`, takže nikdy nezůstane ve zdrojovém kódu.

## Krok 2: Vytvořte modul

Vytvořte složku a inicializujte modul. Není co stahovat.

```bash
mkdir topfilms && cd topfilms
go mod init topfilms
go version
```

`go version` by měl vypsat 1.22 nebo vyšší. Veškerý kód níže vložte do souboru `main.go`.

## Krok 3: Odešlete první požadavek

Nástroj volá `GET /3/discover/movie`, který filtruje a řadí katalog. Žánr `878` je sci-fi a úplný seznam ID je na stránce [žánry](https://amodb.dev/cs/genres/). Tento kompletní program jen vypíše stavový řádek.

```go
package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	url := "https://api.amodb.dev/3/discover/movie?with_genres=878"
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AMODB_KEY"))
	res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

Pro volání API nikdy nepoužívejte `http.DefaultClient`, protože nemá timeout.

## Krok 4: Dekódujte do typovaných struktur

Deklarujte jen pole, která čtete. `encoding/json` zbytek ignoruje, takže nová pole v odpovědi nástroj nerozbijí.

```go
type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}
```

Dotaz žádá o `sort_by=vote_average.desc` a `vote_count.gte=500`. Bez minima hlasů by seznam vedly tituly s jediným hodnocením 10 hvězdiček. Každý filtr je zdokumentován na stránce [discover](https://amodb.dev/cs/discover/).

## Krok 5: Opakujte pokus při 429 a hlaste chyby

Přečtěte tělo jednou a pak rozhodněte podle stavového kódu.

| Stav              | Co dělá klient                                                                             |
| ----------------- | ------------------------------------------------------------------------------------------ |
| 200               | Zapíše soubor cache a dekóduje tělo.                                                       |
| 401               | Vrátí chybu, která uvádí `AMODB_KEY`. Bez opakování, protože další volání by také selhalo. |
| 429               | Spí `Retry-After` sekund (1, když chybí, nejvýše 30) a zkusí to znovu, nejvýše 3 pokusy.   |
| Ostatní 4xx a 5xx | Vrátí `status_code` a `status_message` z těla chyby TMDB.                                  |
| Chyba přenosu     | Vrátí `network error` obalené pomocí `%w`.                                                 |

Pomocná funkce pro prodlevu je malá:

```go
func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}
```

Všechny kódy najdete na stránce [chyby](https://amodb.dev/cs/errors/) a klouzavé okno na stránce [limity požadavků](https://amodb.dev/cs/rate-limits/).

## Krok 6: Ukládejte odpovědi na disk

Edge drží seznamy discover 30 minut. Klient uloží každé úspěšné tělo do uživatelské složky cache pod názvem podle SHA-256 celého URL a znovu ho poskytne, dokud je soubor mladší než TTL. Opakovaný příkaz pak neposílá žádný požadavek.

## Krok 7: Kompletní program

Nahraďte `main.go` tímto a spusťte `go run . -genre 878 -year 2010 -n 10`.

```go
package main

import (
	"crypto/sha256"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const baseURL = "https://api.amodb.dev/3"

type Movie struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	ReleaseDate string  `json:"release_date"`
	VoteAverage float64 `json:"vote_average"`
	VoteCount   int     `json:"vote_count"`
}

type DiscoverPage struct {
	TotalResults int     `json:"total_results"`
	Results      []Movie `json:"results"`
}

type apiError struct {
	StatusCode    int    `json:"status_code"`
	StatusMessage string `json:"status_message"`
}

type Client struct {
	key      string
	hc       *http.Client
	cacheDir string
}

func NewClient(key string) (*Client, error) {
	base, err := os.UserCacheDir()
	if err != nil {
		base = os.TempDir()
	}
	dir := filepath.Join(base, "amodb-top-films")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	return &Client{key: key, hc: &http.Client{Timeout: 10 * time.Second}, cacheDir: dir}, nil
}

func (c *Client) Get(path string, query url.Values, ttl time.Duration, out any) error {
	endpoint := baseURL + path + "?" + query.Encode()
	cacheFile := filepath.Join(c.cacheDir, fmt.Sprintf("%x.json", sha256.Sum256([]byte(endpoint))))
	if info, err := os.Stat(cacheFile); err == nil && time.Since(info.ModTime()) < ttl {
		if body, err := os.ReadFile(cacheFile); err == nil {
			return json.Unmarshal(body, out)
		}
	}

	for attempt := 1; attempt <= 3; attempt++ {
		req, err := http.NewRequest(http.MethodGet, endpoint, nil)
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.key)
		res, err := c.hc.Do(req)
		if err != nil {
			return fmt.Errorf("network error: %w", err)
		}
		body, err := io.ReadAll(res.Body)
		res.Body.Close()
		if err != nil {
			return fmt.Errorf("reading reply: %w", err)
		}

		switch {
		case res.StatusCode == http.StatusOK:
			_ = os.WriteFile(cacheFile, body, 0o644)
			return json.Unmarshal(body, out)
		case res.StatusCode == http.StatusTooManyRequests && attempt < 3:
			time.Sleep(retryDelay(res.Header.Get("Retry-After")))
		default:
			return statusError(res.StatusCode, body)
		}
	}
	return errors.New("retries exhausted")
}

func retryDelay(header string) time.Duration {
	seconds, err := strconv.Atoi(header)
	if err != nil || seconds < 1 {
		seconds = 1
	}
	return time.Duration(min(seconds, 30)) * time.Second
}

func statusError(status int, body []byte) error {
	if status == http.StatusUnauthorized {
		return errors.New("401: the key is invalid or suspended, check AMODB_KEY")
	}
	var e apiError
	if json.Unmarshal(body, &e) == nil && e.StatusMessage != "" {
		return fmt.Errorf("amodb returned %d (status_code %d): %s", status, e.StatusCode, e.StatusMessage)
	}
	return fmt.Errorf("amodb returned %d", status)
}

func yearOf(date string) string {
	if len(date) >= 4 {
		return date[:4]
	}
	return "n/a"
}

func main() {
	genre := flag.Int("genre", 878, "genre id, 878 is Science Fiction")
	year := flag.Int("year", 2010, "primary release year")
	limit := flag.Int("n", 10, "how many movies to print, 1 to 20")
	flag.Parse()

	key := os.Getenv("AMODB_KEY")
	if key == "" {
		fmt.Fprintln(os.Stderr, "Set AMODB_KEY first.")
		os.Exit(1)
	}
	client, err := NewClient(key)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	query := url.Values{}
	query.Set("language", "en-US")
	query.Set("with_genres", strconv.Itoa(*genre))
	query.Set("primary_release_year", strconv.Itoa(*year))
	query.Set("sort_by", "vote_average.desc")
	query.Set("vote_count.gte", "500")

	var page DiscoverPage
	if err := client.Get("/discover/movie", query, 30*time.Minute, &page); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(page.Results) == 0 {
		fmt.Println("No movies matched.")
		return
	}
	fmt.Printf("%d movies match, best first:\n", page.TotalResults)
	for i, m := range page.Results {
		if i >= *limit {
			break
		}
		fmt.Printf("%2d. %s (%s) %.1f from %d votes\n", i+1, m.Title, yearOf(m.ReleaseDate), m.VoteAverage, m.VoteCount)
	}
}
```

Vyzkoušejte jiné filtry pomocí přepínačů. `go run . -genre 18 -year 1999 -n 5` vypíše pět nejlépe hodnocených dramat z roku 1999. Spusťte stejný příkaz dvakrát a druhé spuštění odpoví ze souboru cache, aniž by sáhlo na síť, dokud neuplyne 30 minut. Pokud pro vaši kombinaci nemá žádný film 500 hlasů, nástroj vypíše `No movies matched.` a skončí normálně.

Smyčka opakování se ve všech případech vrací zevnitř `switch`, kromě 429, která má ještě zbývající pokusy. Proto funkce končí prostou chybou: kompilátor potřebuje return za smyčkou a zpráva by se objevila, jen kdyby se změnila logika smyčky.

## Další kroky

- Přidejte přepínač `-page` a předejte ho jako parametr `page`, abyste procházeli seznam dál.
- Vyhledejte každé ID filmu přes [filmy](https://amodb.dev/cs/movies/) a přidejte `append_to_response=ratings`, abyste četli skóre IMDb z `ratings.imdb.value`.
- Spusťte několik žánrů najednou pomocí goroutin a držte se pod požadavky za minutu svého tarifu.
- Přeneste stejný nápad do [Javy](https://amodb.dev/cs/java-movie-api/) nebo si přečtěte [rychlý start](https://amodb.dev/cs/quickstart/).

## Otázky a odpovědi

### Kterou verzi Go kód potřebuje?

Go 1.22 nebo novější. Používá vestavěnou funkci min z Go 1.21, alias any a jen standardní knihovnu, takže go.mod neuvádí žádné závislosti a go run . funguje hned po go mod init.

### Proč typované struktury místo map[string]any?

Struktury dávají názvy polí ověřené při kompilaci a skutečné číselné typy a encoding/json přeskakuje pole, která jste nedeklarovali. Hodí se to pro API, kde se časem mohou objevit nová pole, protože starší kód dál funguje.

### Jak klient čeká po 429?

Čte hlavičku Retry-After jako celé sekundy, při jejím chybění použije 1, omezí čekání na 30 a udělá celkem nejvýše 3 pokusy. Poté vrátí volajícímu stavovou zprávu.

### Mohu posílat požadavky z několika goroutin?

Ano, ale každé volání se stále počítá do 20, 40 nebo 120 požadavků za minutu vašeho klíče. Rozložte volání pomocí time.Ticker nebo bufferovaného kanálu a nechte opakování po 429 zachytit občasné přetečení.

### Kde nástroj drží cache?

Ve složce amodb-top-films pod os.UserCacheDir, s jedním souborem na požadavek pojmenovaným podle hashe SHA-256 z URL. Soubor se používá 30 minut, tedy stejně dlouho, jako edge drží seznamy discover.

## Související stránky

- [Discover API](https://amodb.dev/cs/discover/): Discover API se stejnými filtry jako TMDB v3. Vypisujte filmy a seriály podle žánru, společnosti, klíčového slova, roku, hodnocení, délky a poskytovatele.
- [Žánry a číselníky](https://amodb.dev/cs/genres/): Seznam žánrů API s cestami TMDB v3: ID žánrů filmů a seriálů s přeloženými názvy a také číselníky zemí, jazyků, profesí, věkových hodnocení a poskytovatelů.
- [Limity požadavků](https://amodb.dev/cs/rate-limits/): amodb omezuje klíč počtem požadavků za minutu v klouzavém okně 60 sekund: 20, 40 nebo 120 podle tarifu. Čtěte hlavičky limitu a řešte 429 pomocí Retry-After.
- [Chyby](https://amodb.dev/cs/errors/): Každá chyba amodb se stavem HTTP, status_code a zprávou TMDB a také tvar chyby OMDb. Co která znamená a co má váš klient udělat v dalším kroku.
- [Návod na filmové API v Javě](https://amodb.dev/cs/java-movie-api/): Vytvořte v Javě průvodce epizodami s JDK HttpClient a záznamy Jackson: načtěte sezónu, respektujte Retry-After při 429, ukládejte do souborů. Java 17+.
- [Návod na filmové API v PHP](https://amodb.dev/cs/php-movie-api/): Vytvořte funkci v PHP a malou stránku filmu, které volají API amodb přes cURL, při 429 opakují pokus, escapují výstup a cachují odpovědi do souborů. PHP 8.2+.

Aktualizováno: 2026-10-18. Source: https://amodb.dev/cs/go-movie-api/
