# Poradnik API filmów w Go

> Zbuduj narzędzie wiersza poleceń w Go, które wypisuje najlepiej oceniane filmy wybranego gatunku i roku premiery. Dekoduje JSON do typowanych struktur, ponawia żądanie po 429 po opóźnieniu z Retry-After i zapisuje odpowiedzi na dysku. Używa tylko biblioteki standardowej, wymaga Go 1.22 lub nowszego i zajmuje około 20 minut.

Zbudujesz `topfilms`, narzędzie wiersza poleceń w Go. Uruchom `go run . -genre 878 -year 2010 -n 10`, a wypisze dziesięć najlepiej ocenianych filmów science fiction z 2010 roku, z wynikiem i liczbą głosów. Używa typowanych struktur, tylko biblioteki standardowej i Go 1.22 lub nowszego. Zaplanuj 20 minut.

## Krok 1: Zdobądź klucz API

Zacznij od strony [cennik](https://amodb.dev/pl/pricing/), gdzie wybierasz okres przedpłaty i jeden z planów z 20, 40 lub 120 żądaniami na minutę. Potem utwórz klucz w panelu konta i wyeksportuj go:

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

Program czyta go przez `os.Getenv("AMODB_KEY")`, więc nigdy nie trafia do kodu źródłowego.

## Krok 2: Utwórz moduł

Utwórz folder i zainicjuj moduł. Nie ma nic do pobrania.

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

`go version` powinno wypisać 1.22 lub wyższe. Cały kod poniżej umieść w pliku `main.go`.

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

Narzędzie wywołuje `GET /3/discover/movie`, które filtruje i sortuje katalog. Gatunek `878` to science fiction, a pełna lista ID jest na stronie [gatunki](https://amodb.dev/pl/genres/). Ten kompletny program tylko wypisuje linię statusu.

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

Nigdy nie używaj `http.DefaultClient` do wywołań API, bo nie ma timeoutu.

## Krok 4: Dekoduj do typowanych struktur

Zadeklaruj tylko pola, które czytasz. `encoding/json` ignoruje resztę, więc nowe pola w odpowiedzi nie zepsują narzędzia.

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

Zapytanie prosi o `sort_by=vote_average.desc` i `vote_count.gte=500`. Bez progu głosów listę otwierałyby tytuły z jednym głosem na 10 gwiazdek. Każdy filtr jest opisany na stronie [discover](https://amodb.dev/pl/discover/).

## Krok 5: Ponawiaj po 429 i zgłaszaj błędy

Przeczytaj treść raz, potem zdecyduj według kodu statusu.

| Status          | Co robi klient                                                                             |
| --------------- | ------------------------------------------------------------------------------------------ |
| 200             | Zapisuje plik cache i dekoduje treść.                                                      |
| 401             | Zwraca błąd wskazujący `AMODB_KEY`. Bez ponawiania, bo następne wywołanie też by zawiodło. |
| 429             | Śpi `Retry-After` sekund (1, gdy brak, najwyżej 30) i próbuje ponownie, do 3 prób.         |
| Inne 4xx i 5xx  | Zwraca `status_code` i `status_message` z treści błędu TMDB.                               |
| Błąd transportu | Zwraca `network error` opakowane przez `%w`.                                               |

Funkcja pomocnicza opóźnienia jest mała:

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

Wszystkie kody znajdziesz na stronie [błędy](https://amodb.dev/pl/errors/), a przesuwane okno na stronie [limity żądań](https://amodb.dev/pl/rate-limits/).

## Krok 6: Zapisuj odpowiedzi na dysku

Edge trzyma listy discover przez 30 minut. Klient zapisuje każdą udaną treść w folderze cache użytkownika, pod nazwą z SHA-256 pełnego URL, i podaje ją ponownie, dopóki plik jest młodszy niż TTL. Powtórzone polecenie nie wysyła wtedy żadnego żądania.

## Krok 7: Kompletny program

Zastąp `main.go` tym kodem i uruchom `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)
	}
}
```

Wypróbuj inne filtry przez flagi. `go run . -genre 18 -year 1999 -n 5` wypisuje pięć najlepiej ocenianych dramatów z 1999 roku. Uruchom to samo polecenie dwa razy, a drugie uruchomienie odpowie z pliku cache, bez dotykania sieci, dopóki nie minie 30 minut. Jeśli żaden film dla twojej kombinacji nie ma 500 głosów, narzędzie wypisuje `No movies matched.` i kończy normalnie.

Pętla ponowień zwraca wynik z wnętrza `switch` w każdym przypadku poza 429, która ma jeszcze pozostałe próby. Dlatego funkcja kończy się zwykłym błędem: kompilator potrzebuje return po pętli, a komunikat pojawiłby się tylko wtedy, gdyby zmieniła się logika pętli.

## Kolejne kroki

- Dodaj flagę `-page` i przekaż ją jako parametr `page`, by przechodzić dalej w dół listy.
- Pobierz każde ID filmu przez [filmy](https://amodb.dev/pl/movies/) i dodaj `append_to_response=ratings`, by czytać wynik IMDb z `ratings.imdb.value`.
- Uruchom kilka gatunków naraz za pomocą goroutines i trzymaj się poniżej żądań na minutę twojego planu.
- Przenieś ten sam pomysł do [Java](https://amodb.dev/pl/java-movie-api/) albo przeczytaj [szybki start](https://amodb.dev/pl/quickstart/).

## Pytania i odpowiedzi

### Jakiej wersji Go wymaga kod?

Go 1.22 lub nowszego. Używa wbudowanej funkcji min z Go 1.21, aliasu any i tylko biblioteki standardowej, więc go.mod nie wymienia żadnych zależności, a go run . działa zaraz po go mod init.

### Dlaczego typowane struktury zamiast map[string]any?

Struktury dają nazwy pól sprawdzane w czasie kompilacji i prawdziwe typy liczbowe, a encoding/json pomija pola, których nie zadeklarowano. To pasuje do API, w którym z czasem mogą pojawić się nowe pola, bo starszy kod dalej działa.

### Jak klient czeka po 429?

Czyta nagłówek Retry-After jako pełne sekundy, przyjmuje 1, gdy nagłówka brak, ogranicza czekanie do 30 i robi łącznie najwyżej 3 próby. Potem zwraca wywołującemu komunikat statusu.

### Czy mogę wysyłać żądania z kilku goroutines?

Tak, ale każde wywołanie nadal liczy się do 20, 40 lub 120 żądań na minutę twojego klucza. Rozłóż wywołania przez time.Ticker lub kanał buforowany i pozwól, by ponawianie po 429 złapało sporadyczne przekroczenie.

### Gdzie narzędzie trzyma cache?

W folderze amodb-top-films w os.UserCacheDir, z jednym plikiem na żądanie nazwanym od skrótu SHA-256 z URL. Plik jest używany przez 30 minut, tyle samo, ile edge trzyma listy discover.

## Powiązane strony

- [Discover API](https://amodb.dev/pl/discover/): Discover API z tymi samymi filtrami co TMDB v3. Wypisuj filmy i seriale według gatunku, obsady, firmy, słowa kluczowego, roku, oceny, czasu trwania i dostawcy.
- [Gatunki i słowniki](https://amodb.dev/pl/genres/): Lista gatunków API ze ścieżkami TMDB v3: ID gatunków filmów i seriali z nazwami w 14 językach oraz słowniki krajów, języków, stanowisk i dostawców streamingu.
- [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.
- [Błędy](https://amodb.dev/pl/errors/): Każdy błąd amodb ze statusem HTTP, status_code i komunikatem TMDB oraz kształt błędu OMDb. Co oznacza każdy z nich i co powinien zrobić twój klient dalej.
- [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+.
- [Poradnik API filmów w PHP](https://amodb.dev/pl/php-movie-api/): Zbuduj funkcję PHP i małą stronę filmu, które wywołują API amodb przez cURL, ponawiają po 429, escapują wyjście i zapisują każdą odpowiedź w pliku. PHP 8.2+.

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