@get-air/simkl
v0.1.0
Published
Promise and Effect APIs for the fully typed Simkl API client
Maintainers
Readme
@get-air/simkl
A fully typed TypeScript client for the complete Simkl API. The default API uses ordinary Promises and JavaScript values. Effect applications get the same implementation, with typed errors and Layers, from @get-air/simkl/effect.
- All 49 operations from Simkl's OpenAPI 3.1 document, grouped by domain.
- Fully inferred path, query, header, body, and response types.
- OAuth 2.0, PKCE, and PIN/device authentication flows.
- Automatic app identity and optional bearer-token injection.
- Automatic routing between
api.simkl.com,simkl.com, and thedata.simkl.inCDN. - Tagged errors for Promise users and typed Effect errors for Effect users.
- Pagination and rate-limit response metadata.
- Manual redirect preservation for
/redirectand image redirect endpoints. - Simkl poster, fanart, episode-image, and fallback URL helpers.
- Direct object-oriented construction and first-class Tauri v2 transport/caching adapters.
Install
pnpm add @get-air/simklPromise API
The root import does not require callers to import or understand Effect:
import { Simkl, isSimklError } from "@get-air/simkl"
const simkl = await Simkl.make({
clientId: "your-client-id",
appName: "my-app",
appVersion: "1.0.0",
accessToken: "the-user-access-token",
})
try {
const movie = await simkl.movies.details({
params: { path: { id: "5392" } },
})
console.log(movie.data.title, movie.rateLimit.remaining)
} catch (error) {
if (isSimklError(error)) {
console.error(error._tag, error.message)
}
}Promise responses use normal optional values for pagination, rate limits, and redirect locations.
Effect API
Install Effect directly when using the Effect entrypoint:
pnpm add @get-air/simkl effectCreate a client Layer
import { Effect, Redacted } from "effect"
import { SimklClient } from "@get-air/simkl/effect"
const SimklLive = SimklClient.Default({
clientId: "your-client-id",
appName: "my-app",
appVersion: "1.0.0",
accessToken: Redacted.make("the-user-access-token"),
})
const program = Effect.gen(function* () {
const simkl = yield* SimklClient
const movie = yield* simkl.movies.details({
params: { path: { id: "5392" } },
})
if (movie._tag === "SimklResponse") {
yield* Effect.log("Loaded a movie", { title: movie.data.title })
}
})
Effect.runPromise(program.pipe(Effect.provide(SimklLive)))client_id, app-name, app-version, and User-Agent are injected automatically. Pass an accessToken to add the bearer header to API requests. Values supplied to Redacted are not included in logs or error rendering.
Direct Effect client
When a Layer is unnecessary, construct a class instance inside an Effect. Every network operation still returns an Effect:
import { Effect } from "effect"
import { Simkl } from "@get-air/simkl/effect"
const program = Effect.gen(function* () {
const simkl = yield* Simkl.make({
clientId: "your-client-id",
appName: "my-app",
appVersion: "1.0.0",
fetch: customFetch,
})
return yield* simkl.tv.details({ params: { path: { id: "5483" } } })
})fetch accepts any (Request) => Promise<Response> implementation. Browser fetch, Tauri HTTP, test doubles, proxy transports, and cached transports all use the same seam.
Air applications can pass their shared @get-air/http transport and @get-air/cache store directly:
const simkl = await Simkl.make({
clientId,
appName,
appVersion,
transport,
cache,
})The shared cache adapter preserves Simkl's safe default policy: only public GET resources are cached, and configured addon/user credentials are never included in cache keys.
Tauri v2
Install the optional plugins in the consuming Tauri application:
pnpm tauri add http
pnpm tauri add store
pnpm add @tauri-apps/plugin-http @tauri-apps/plugin-storeThen create a Rust-backed client with optional persistent caching:
import { Effect, Option } from "effect"
import { makeTauriSimkl } from "@get-air/simkl/effect/tauri"
const program = Effect.gen(function* () {
const simkl = yield* makeTauriSimkl(
{
clientId: "your-client-id",
appName: "my-tauri-app",
appVersion: "1.0.0",
},
{
http: { connectTimeout: 10_000, maxRedirections: 5 },
cache: {
storePath: "simkl-http-cache.json",
defaultTtlMs: 15 * 60 * 1_000,
},
},
)
const movie = yield* simkl.movies.details({ params: { path: { id: "5392" } } })
// Cache management is available when caching was enabled.
yield* Option.match(simkl.cache, {
onNone: () => Effect.void,
onSome: (cache) => cache.save(),
})
return movie
})Use cache: true for all defaults, omit cache to disable it, or provide options. The built-in policy caches only public GET routes and CDN data; it does not cache Sync, Scrobble, Ratings, settings, OAuth/PIN, POST, or DELETE traffic. Cache keys are SHA-256 hashes and never contain bearer tokens. Cache failures are fail-open, so storage trouble does not take the API offline.
Tauri capabilities must permit the three Simkl origins:
{
"permissions": [
{
"identifier": "http:default",
"allow": [
{ "url": "https://api.simkl.com/**" },
{ "url": "https://simkl.com/**" },
{ "url": "https://data.simkl.in/**" }
]
},
"store:default"
]
}Simkl requires User-Agent, which is a forbidden Fetch header. Enable Tauri HTTP's unsafe-headers Rust feature so the plugin does not discard it:
[dependencies]
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }If persistent caching is not wanted, pass the plugin transport directly:
import { tauriFetch } from "@get-air/simkl/effect/tauri"
const simkl = yield* Simkl.make({
clientId,
appName,
appVersion,
fetch: tauriFetch,
})For environment-based applications, provide SimklClient.layerConfig and set:
SIMKL_CLIENT_ID=your-client-id
SIMKL_APP_NAME=my-app
SIMKL_APP_VERSION=1.0.0
SIMKL_ACCESS_TOKEN=optional-user-tokenEndpoint groups
After yielding SimklClient, the following typed groups are available:
| Group | Methods |
| --- | --- |
| anime | details, airing, best, episodes, genres, premieres |
| auth | authorize, requestPin, exchangeToken, pollPin |
| calendar | rolling, monthly |
| changes | recent |
| movies | details, genres |
| ratings | community |
| redirect | resolve |
| scrobble | checkIn, pause, start, stop |
| search | byFile, byId, random, byText |
| sync | activities, addToList, allItems, addHistory, removeHistory, deletePlayback, allPlayback, playbackByType, addRatings, removeRatings, userRatings, watched |
| trending | combined, byType, dvd |
| tv | details, airing, best, episodes, genres, premieres |
| users | recentlyWatchedBackground, settings, stats |
The lower-level get, post, and del methods accept every generated OpenAPI path and provide the same inference. They are useful when regenerated types pick up a new Simkl endpoint before a named convenience method is released.
const result = yield* simkl.get("/search/{type}", {
params: {
path: { type: "movie" },
query: { q: "The Matrix", extended: "full" },
},
})Authentication
Confidential OAuth
Use auth.authorize for the browser-facing authorization URL and auth.exchangeToken for the code exchange. Keep client_secret on a trusted server. Simkl tokens are long-lived and do not use a refresh-token rotation flow.
Public PKCE
Public apps must not ship a client secret. makePkcePair generates a verifier and S256 challenge with Web Crypto:
const pkce = yield* makePkcePair()
yield* simkl.auth.authorize({
params: {
query: {
redirect_uri: "my-app://oauth",
response_type: "code",
code_challenge: pkce.challenge,
code_challenge_method: pkce.method,
},
},
redirect: "manual",
parseAs: "text",
})Persist the verifier until the callback, then pass it as code_verifier to auth.exchangeToken.
PIN/device flow
Call auth.requestPin, display the returned user_code and verification URL, then poll auth.pollPin at the response's interval. Stop immediately when the access token is returned. Simkl may create a new code if a completed or unknown code is polled again.
Redirects
For endpoints that return a 301 or 302, pass redirect: "manual" and parseAs: "text". The Effect succeeds with SimklRedirectResponse; its location is an Option<string>.
const redirect = yield* simkl.redirect.resolve({
params: { query: { to: "simkl", type: "movie", tmdb: 603 } },
redirect: "manual",
parseAs: "text",
})Errors and rate limits
Failures remain in the Effect error channel and can be handled precisely:
yield* simkl.sync.activities().pipe(
Effect.catchTag("SimklUnauthorizedError", () => beginLogin),
Effect.catchTag("SimklRateLimitExceededError", (error) =>
Option.match(error.retryAfterSeconds, {
onNone: () => Effect.sleep("1 second"),
onSome: (seconds) => Effect.sleep(`${seconds} seconds`),
}),
),
)Successful responses expose parsed pagination and rateLimit metadata as Effect Options. Simkl currently limits applications and user tokens to 10 GET requests/second and 1 POST request/second; batch sync writes and use sync.activities before downloading user lists.
Images
Image helpers follow Simkl's recommended wsrv.nl proxy, size suffixes, quality 90, and documented fallbacks:
import { fanartUrl, posterUrl } from "@get-air/simkl"
const poster = posterUrl(movie.poster, "c")
const hero = fanartUrl(movie.fanart, "medium")Regenerate from OpenAPI
pnpm generateGeneration downloads Simkl's current OpenAPI document, repairs its known ErrorEnvelope/ErrorResponse alias, and emits immutable types plus runtime operation metadata. The contract test fails if the generated operation set and the convenience catalog diverge.
Tests
pnpm test
pnpm check
pnpm buildThe opt-in live test reads credentials through Effect Config:
SIMKL_CLIENT_ID=... \
SIMKL_APP_NAME=get-air-simkl-tests \
SIMKL_APP_VERSION=0.1.0 \
pnpm test:integrationNo credential is stored in the repository.
