From 3a7151e3ae7613b33f8a8aa084f263797b0cfaf6 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 01:16:47 +0200 Subject: [PATCH] Add one-command Unraid media-node install via Master. Co-authored-by: Cursor --- apps/admin-ui/src/app/nodes/page.tsx | 63 ++++++++- apps/master-api/src/app.ts | 2 + apps/master-api/src/install/routes.ts | 44 ++++++ deploy/docker/Dockerfile.master-api | 11 ++ deploy/docker/Dockerfile.media-node | 19 +++ deploy/node/build.sh | 2 +- deploy/node/docker-compose.unraid.yml | 33 +++++ deploy/node/install-unraid.sh | 165 ++++++++++++++++++++++ docs/dokploy.md | 4 +- docs/unraid-node.md | 13 ++ node/media-node/cmd/media-node/main.go | 28 +++- node/media-node/internal/config/config.go | 60 +++++++- 12 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 apps/master-api/src/install/routes.ts create mode 100644 deploy/docker/Dockerfile.media-node create mode 100644 deploy/node/docker-compose.unraid.yml create mode 100644 deploy/node/install-unraid.sh create mode 100644 docs/unraid-node.md diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index 426eb7e..98cc04c 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -20,6 +20,8 @@ interface Node { revoked: boolean; } +const MASTER_URL = "https://master.vonas.nl"; + export default function NodesPage() { useAuth(); const [nodes, setNodes] = useState([]); @@ -27,6 +29,10 @@ export default function NodesPage() { const [enrollName, setEnrollName] = useState(""); const [enrollLocation, setEnrollLocation] = useState(""); const [enrollToken, setEnrollToken] = useState(""); + const [publicUrl, setPublicUrl] = useState("https://node01.vonas.nl"); + const [moviesPath, setMoviesPath] = useState("/mnt/user/movies"); + const [seriesPath, setSeriesPath] = useState("/mnt/user/series"); + const [copied, setCopied] = useState(false); const [editId, setEditId] = useState(null); const [editName, setEditName] = useState(""); const [editLocation, setEditLocation] = useState(""); @@ -54,6 +60,23 @@ export default function NodesPage() { setEnrollToken(data.token); } + function installCommand() { + const series = seriesPath.trim() + ? ` \\\n --series='${seriesPath.trim()}'` + : ""; + return `curl -fsSL ${MASTER_URL}/install/unraid.sh | bash -s -- \\ + --token='${enrollToken}' \\ + --name='${enrollName || "Unraid-Node-01"}' \\ + --public-url='${publicUrl.trim()}' \\ + --movies='${moviesPath.trim()}'${series}`; + } + + async function copyInstall() { + await navigator.clipboard.writeText(installCommand()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + async function rescanNode(id: string) { const res = await fetch(`/api/v1/admin/nodes/${id}/rescan`, { method: "POST", @@ -111,18 +134,50 @@ export default function NodesPage() { {showEnroll && (

- Enrollment Token + Unraid-node toevoegen

+

+ Genereer een token, vul de paden in, kopieer het commando en plak het in de Unraid-terminal. + Geen Docker Compose of image build nodig. +

- setEnrollName(e.target.value)} placeholder="Rotterdam-Node-01" /> + setEnrollName(e.target.value)} placeholder="Unraid-Node-01" />
- setEnrollLocation(e.target.value)} placeholder="Rotterdam" /> + setEnrollLocation(e.target.value)} placeholder="Thuis" /> +
+
+ + setPublicUrl(e.target.value)} placeholder="https://node01.vonas.nl" /> +
+
+ + setMoviesPath(e.target.value)} placeholder="/mnt/user/movies" /> +
+
+ + setSeriesPath(e.target.value)} placeholder="/mnt/user/series" />
- {enrollToken &&
{enrollToken}
} + {enrollToken && ( + <> +
{enrollToken}
+

+ Unraid-terminal +

+
+                  {installCommand()}
+                
+ +

+ NPM: {publicUrl || "node-URL"} → Unraid LAN-IP:8080, Force SSL, buffering uit. +

+ + )}
)} diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index b749e57..8627f34 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -9,6 +9,7 @@ import { registerAuthRoutes, ensureAdminUser } from "./auth/routes"; import { registerNodeRoutes } from "./nodes/routes"; import { registerStremioRoutes } from "./stremio/routes"; import { registerAdminRoutes } from "./admin/routes"; +import { registerInstallRoutes } from "./install/routes"; import { nodeConnectionManager } from "./websocket/manager"; import { toErrorResponse } from "./security/errors"; @@ -63,6 +64,7 @@ async function main() { await registerNodeRoutes(app); await registerStremioRoutes(app, config); await registerAdminRoutes(app, config); + await registerInstallRoutes(app, config); app.get("/api/v1/node/connect", { websocket: true }, (socket) => { void nodeConnectionManager.handleConnection(socket); diff --git a/apps/master-api/src/install/routes.ts b/apps/master-api/src/install/routes.ts new file mode 100644 index 0000000..c7ca979 --- /dev/null +++ b/apps/master-api/src/install/routes.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from "fastify"; +import fs from "fs"; +import path from "path"; +import type { Config } from "../config"; + +function installDir(): string { + const candidates = [ + path.join(process.cwd(), "install"), + path.join(__dirname, "..", "..", "install"), + "/app/install", + ]; + for (const dir of candidates) { + if (fs.existsSync(path.join(dir, "unraid.sh"))) return dir; + } + return candidates[0]; +} + +export async function registerInstallRoutes(app: FastifyInstance, config: Config) { + const dir = installDir(); + + app.get("/install/unraid.sh", async (_request, reply) => { + const file = path.join(dir, "unraid.sh"); + if (!fs.existsSync(file)) { + return reply.status(404).send("Install script not found — redeploy Master with install assets."); + } + let script = fs.readFileSync(file, "utf8"); + script = script.replaceAll("__MASTER_PUBLIC_URL__", config.PUBLIC_URL.replace(/\/$/, "")); + reply.header("Content-Type", "text/x-shellscript; charset=utf-8"); + reply.header("Content-Disposition", 'inline; filename="unraid.sh"'); + return reply.send(script); + }); + + for (const bin of ["media-node-linux-amd64", "media-node-linux-arm64"] as const) { + app.get(`/install/${bin}`, async (_request, reply) => { + const file = path.join(dir, bin); + if (!fs.existsSync(file)) { + return reply.status(404).send(`${bin} not available on this Master build`); + } + reply.header("Content-Type", "application/octet-stream"); + reply.header("Content-Disposition", `attachment; filename="${bin}"`); + return reply.send(fs.createReadStream(file)); + }); + } +} diff --git a/deploy/docker/Dockerfile.master-api b/deploy/docker/Dockerfile.master-api index ea13f76..c681372 100644 --- a/deploy/docker/Dockerfile.master-api +++ b/deploy/docker/Dockerfile.master-api @@ -2,6 +2,14 @@ FROM node:20-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.4 --activate WORKDIR /app +FROM golang:1.22-alpine AS node-build +WORKDIR /src +RUN apk add --no-cache git ca-certificates +COPY node/media-node/ ./ +RUN go mod tidy \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node-linux-amd64 ./cmd/media-node \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node-linux-arm64 ./cmd/media-node + FROM base AS builder COPY package.json pnpm-workspace.yaml ./ COPY apps/master-api ./apps/master-api @@ -31,6 +39,9 @@ COPY --from=builder /out/dist ./dist COPY --from=builder /out/prisma ./prisma COPY --from=builder /out/package.json ./package.json COPY --from=builder /out/local-packages ./local-packages +COPY --from=node-build /media-node-linux-amd64 /app/install/media-node-linux-amd64 +COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm64 +COPY deploy/node/install-unraid.sh /app/install/unraid.sh # Remove workspace protocol deps before npm install RUN node -e "\ diff --git a/deploy/docker/Dockerfile.media-node b/deploy/docker/Dockerfile.media-node new file mode 100644 index 0000000..643485c --- /dev/null +++ b/deploy/docker/Dockerfile.media-node @@ -0,0 +1,19 @@ +# Media Node — static Go binary for Unraid / Docker +FROM golang:1.22-alpine AS build +WORKDIR /src +RUN apk add --no-cache git ca-certificates +COPY node/media-node/ ./ +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node ./cmd/media-node + +FROM alpine:3.20 +# uid 99 / gid 100 = Unraid nobody:users +RUN apk add --no-cache ca-certificates tzdata \ + && mkdir -p /etc/media-node/credentials /var/lib/media-node \ + && chown -R 99:100 /etc/media-node /var/lib/media-node +COPY --from=build /media-node /usr/local/bin/media-node +USER 99:100 +ENV MEDIA_NODE_CONFIG=/etc/media-node/config.yaml \ + MEDIA_NODE_DATA=/var/lib/media-node \ + MEDIA_NODE_CREDENTIALS=/etc/media-node/credentials/credentials.json +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/media-node", "run"] diff --git a/deploy/node/build.sh b/deploy/node/build.sh index 1c1f557..cc2f564 100644 --- a/deploy/node/build.sh +++ b/deploy/node/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -cd "$(dirname "$0")/../node/media-node" +cd "$(dirname "$0")/../../node/media-node" VERSION="${1:-1.0.0}" OUTPUT_DIR="../../dist" diff --git a/deploy/node/docker-compose.unraid.yml b/deploy/node/docker-compose.unraid.yml new file mode 100644 index 0000000..61c96c1 --- /dev/null +++ b/deploy/node/docker-compose.unraid.yml @@ -0,0 +1,33 @@ +# Voorbeeld voor Unraid (Docker Compose / Compose Manager) +# Pas paden, token en public URL aan vóór starten. +# +# Build vanaf repo-root: +# docker compose -f deploy/node/docker-compose.unraid.yml build +# docker compose -f deploy/node/docker-compose.unraid.yml up -d + +services: + media-node: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.media-node + image: media-node:1.0.0 + container_name: media-node + restart: unless-stopped + ports: + - "8080:8080" + environment: + MEDIA_NODE_NAME: "Unraid-Node-01" + MEDIA_NODE_MASTER_URL: "https://master.vonas.nl" + MEDIA_NODE_PUBLIC_URL: "https://node01.vonas.nl" + # Alleen nodig bij eerste start — daarna credentials-volume bewaren en token weghalen + MEDIA_NODE_ENROLLMENT_TOKEN: "PLAK_TOKEN_HIER" + # Paden BÍNnen de container (zie volumes hieronder) + MEDIA_NODE_MOVIES: "/media/movies" + MEDIA_NODE_SERIES: "/media/series" + TZ: "Europe/Amsterdam" + volumes: + - /mnt/user/appdata/media-node/config:/etc/media-node + - /mnt/user/appdata/media-node/data:/var/lib/media-node + # Pas aan naar jouw Unraid shares (read-only) + - /mnt/user/movies:/media/movies:ro + - /mnt/user/series:/media/series:ro diff --git a/deploy/node/install-unraid.sh b/deploy/node/install-unraid.sh new file mode 100644 index 0000000..13f08d7 --- /dev/null +++ b/deploy/node/install-unraid.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# Unraid media-node installer — geen image build, alleen download + docker run +set -euo pipefail + +MASTER_URL="${MASTER_URL:-__MASTER_PUBLIC_URL__}" +TOKEN="" +NAME="" +PUBLIC_URL="" +MOVIES="" +SERIES="" +PORT="${PORT:-8080}" +APPDATA="${APPDATA:-/mnt/user/appdata/media-node}" + +usage() { + cat </dev/null 2>&1; then + echo "Docker niet gevonden. Op Unraid: Docker service aanzetten." + exit 1 +fi + +prompt() { + local label="$1" var="$2" current="$3" flag="$4" + if [ -n "$current" ]; then + eval "$var=\"\$current\"" + return + fi + if [ -t 0 ]; then + local val="" + printf "%s" "$label" + read -r val + eval "$var=\"\$val\"" + return + fi + echo "Ontbrekende optie: $flag (bij curl|bash alle flags meegeven)." + usage + exit 1 +} + +echo "=== STH Media Node — Unraid install ===" +echo "Master: $MASTER_URL" +echo + +prompt "Enrollment token: " TOKEN "$TOKEN" "--token" +prompt "Node-naam [Unraid-Node-01]: " NAME "${NAME}" "--name" +if [ -z "$NAME" ]; then NAME="Unraid-Node-01"; fi +prompt "Publieke URL (https://node01....): " PUBLIC_URL "$PUBLIC_URL" "--public-url" +prompt "Films-pad [/mnt/user/movies]: " MOVIES "${MOVIES}" "--movies" +if [ -z "$MOVIES" ] && [ -t 0 ]; then MOVIES="/mnt/user/movies"; fi +if [ -z "$MOVIES" ]; then + echo "Ontbrekende optie: --movies" + exit 1 +fi +if [ -t 0 ] && [ -z "$SERIES" ]; then + printf "Series-pad (leeg = overslaan): " + read -r SERIES || true +fi + +TOKEN="$(echo -n "$TOKEN" | tr -d '\r\n')" +NAME="$(echo -n "$NAME" | tr -d '\r\n')" +PUBLIC_URL="$(echo -n "$PUBLIC_URL" | sed 's:/*$::')" +MOVIES="$(echo -n "$MOVIES" | tr -d '\r\n')" +SERIES="$(echo -n "$SERIES" | tr -d '\r\n')" +MASTER_URL="$(echo -n "$MASTER_URL" | sed 's:/*$::')" + +if [ -z "$TOKEN" ] || [ -z "$NAME" ] || [ -z "$PUBLIC_URL" ] || [ -z "$MOVIES" ]; then + echo "Token, naam, public-url en movies zijn verplicht." + exit 1 +fi + +if [ ! -d "$MOVIES" ]; then + echo "Waarschuwing: films-pad bestaat niet: $MOVIES" +fi +if [ -n "$SERIES" ] && [ ! -d "$SERIES" ]; then + echo "Waarschuwing: series-pad bestaat niet: $SERIES" +fi + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;; + aarch64|arm64) BIN_NAME="media-node-linux-arm64" ;; + *) echo "Niet-ondersteunde architectuur: $ARCH"; exit 1 ;; +esac + +mkdir -p "$APPDATA/config/credentials" "$APPDATA/data" "$APPDATA/bin" +chown -R 99:100 "$APPDATA" 2>/dev/null || true + +BIN_PATH="$APPDATA/bin/media-node" +echo "Binary downloaden ($BIN_NAME)..." +curl -fsSL "$MASTER_URL/install/$BIN_NAME" -o "$BIN_PATH" +chmod +x "$BIN_PATH" + +# Oude container weg +docker rm -f media-node >/dev/null 2>&1 || true + +# Alpine met gemounte static binary — geen custom image build +VOLUMES=( + -v "$APPDATA/config:/etc/media-node" + -v "$APPDATA/data:/var/lib/media-node" + -v "$BIN_PATH:/usr/local/bin/media-node:ro" + -v /etc/ssl/certs:/etc/ssl/certs:ro + -v "$MOVIES:/media/movies:ro" +) +ENV_SERIES="" +if [ -n "$SERIES" ]; then + VOLUMES+=(-v "$SERIES:/media/series:ro") + ENV_SERIES="/media/series" +fi + +echo "Container starten..." +docker run -d \ + --name media-node \ + --restart unless-stopped \ + -p "${PORT}:8080" \ + -e "MEDIA_NODE_NAME=$NAME" \ + -e "MEDIA_NODE_MASTER_URL=$MASTER_URL" \ + -e "MEDIA_NODE_PUBLIC_URL=$PUBLIC_URL" \ + -e "MEDIA_NODE_MOVIES=/media/movies" \ + -e "MEDIA_NODE_SERIES=$ENV_SERIES" \ + -e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \ + -e "TZ=${TZ:-Europe/Amsterdam}" \ + --user 99:100 \ + "${VOLUMES[@]}" \ + --entrypoint /usr/local/bin/media-node \ + alpine:3.20 \ + run + +echo +echo "Klaar. Logs: docker logs -f media-node" +echo "Status: in Admin → Nodes (moet online worden)" +echo "NPM: $PUBLIC_URL → Unraid-LAN-IP:$PORT (buffering uit, Force SSL)" +echo +echo "Na succes: token staat opgeslagen in appdata — herstart zonder nieuwe token is ok." diff --git a/docs/dokploy.md b/docs/dokploy.md index 339b31c..c01a5c4 100644 --- a/docs/dokploy.md +++ b/docs/dokploy.md @@ -139,8 +139,8 @@ Browsers roepen alleen `/api/...` op het admin-domein aan — dat is correct. ## Na succesvolle deploy 1. Admin UI → **Instellingen** → Stremio addon token genereren -2. Admin UI → **Nodes** → enrollment token -3. Op een Media Node: `sudo media-node install` met +2. Admin UI → **Nodes** → token + **Kopieer install-commando** (Unraid: één curl|bash) +3. Op Unraid: zie [unraid-node.md](./unraid-node.md). Op Linux: `sudo media-node install` met Master URL = `https://master.jouwdomein.nl` ## Alternatief: Dokploy + jouw NPM diff --git a/docs/unraid-node.md b/docs/unraid-node.md new file mode 100644 index 0000000..f3ded17 --- /dev/null +++ b/docs/unraid-node.md @@ -0,0 +1,13 @@ +# Media Node op Unraid (eenvoudig) + +Na Master-redeploy (met install-assets) heb je **geen** Compose of image build nodig. + +## Stappen + +1. **NPM**: Proxy Host voor bijv. `node01.vonas.nl` → Unraid LAN-IP `:8080`, Force SSL, buffering uit (zie `npm-setup.md`). +2. **Admin** → Nodes → **Node toevoegen** → token + paden → **Kopieer install-commando**. +3. Plak het commando in de **Unraid-terminal** (of SSH). + +Het script downloadt de binary van de Master en start één Alpine-container. + +Controle: `docker logs -f media-node` en in Admin of de node **online** is. diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index 1c73201..04af533 100644 --- a/node/media-node/cmd/media-node/main.go +++ b/node/media-node/cmd/media-node/main.go @@ -82,6 +82,8 @@ func runService() { dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir) credsPath := envOr("MEDIA_NODE_CREDENTIALS", config.DefaultCredentialsPath) + ensureDirs(filepath.Dir(configPath), dataDir, filepath.Dir(credsPath)) + cfg, err := config.Load(configPath) if err != nil { log.Fatalf("Failed to load config: %v", err) @@ -89,7 +91,22 @@ func runService() { creds, err := loadCredentials(credsPath) if err != nil { - log.Fatalf("Failed to load credentials: %v", err) + token := strings.TrimSpace(os.Getenv("MEDIA_NODE_ENROLLMENT_TOKEN")) + if token == "" { + log.Fatalf("Failed to load credentials: %v (set MEDIA_NODE_ENROLLMENT_TOKEN for first boot)", err) + } + log.Printf("No credentials at %s — enrolling with Master...", credsPath) + creds, err = enroll(cfg.Master.URL, token, cfg.Node.Name, cfg.Stream.PublicURL) + if err != nil { + log.Fatalf("Enrollment failed: %v", err) + } + data, _ := json.MarshalIndent(creds, "", " ") + if writeErr := os.WriteFile(credsPath, data, 0o600); writeErr != nil { + log.Fatalf("Failed to save credentials: %v", writeErr) + } + log.Printf("Enrolled as node %s — credentials saved", creds.NodeID) + cfg.Node.ID = creds.NodeID + _ = config.Save(configPath, cfg) } store, err := database.Open(dataDir) @@ -451,3 +468,12 @@ func nonEmpty(s string) []string { } return []string{s} } + +func ensureDirs(paths ...string) { + for _, p := range paths { + if p == "" || p == "." { + continue + } + _ = os.MkdirAll(p, 0o750) + } +} diff --git a/node/media-node/internal/config/config.go b/node/media-node/internal/config/config.go index 0f308c7..d881040 100644 --- a/node/media-node/internal/config/config.go +++ b/node/media-node/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strings" "time" "gopkg.in/yaml.v3" @@ -61,12 +62,52 @@ type Credentials struct { func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { + if os.IsNotExist(err) { + cfg, envErr := FromEnv() + if envErr != nil { + return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr) + } + if saveErr := Save(path, cfg); saveErr != nil { + // Still usable in-memory when the path is not writable. + return applyDefaults(cfg), nil + } + return applyDefaults(cfg), nil + } return nil, fmt.Errorf("read config: %w", err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } + return applyDefaults(&cfg), nil +} + +// FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid). +func FromEnv() (*Config, error) { + name := strings.TrimSpace(os.Getenv("MEDIA_NODE_NAME")) + master := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_MASTER_URL")), "/") + publicURL := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_PUBLIC_URL")), "/") + if name == "" || master == "" || publicURL == "" { + return nil, fmt.Errorf("MEDIA_NODE_NAME, MEDIA_NODE_MASTER_URL and MEDIA_NODE_PUBLIC_URL are required") + } + listen := strings.TrimSpace(os.Getenv("MEDIA_NODE_LISTEN")) + if listen == "" { + listen = DefaultListen + } + return &Config{ + Node: NodeConfig{Name: name}, + Master: MasterConfig{URL: master}, + Stream: StreamConfig{Listen: listen, PublicURL: publicURL}, + Media: MediaConfig{ + Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")), + Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")), + }, + Scanner: ScannerConfig{FullScanInterval: "6h"}, + Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 8}, + }, nil +} + +func applyDefaults(cfg *Config) *Config { if cfg.Stream.Listen == "" { cfg.Stream.Listen = DefaultListen } @@ -79,7 +120,24 @@ func Load(path string) (*Config, error) { if cfg.Security.MaxConcurrentPerIP == 0 { cfg.Security.MaxConcurrentPerIP = 8 } - return &cfg, nil + return cfg +} + +func splitPaths(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, part := range strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' + }) { + p := strings.TrimSpace(part) + if p != "" { + out = append(out, p) + } + } + return out } func (c *Config) FullScanDuration() time.Duration {