diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..367b946 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# .env に写して、この機械に合わせて書く。.env はコミットしない。 + +# データベースのパスワード。空のままでは起動しない。`;` は使えない +POSTGRES_PASSWORD= + +# データベースのデータを置くホスト側のディレクトリ +CARINA_DB_DIR= + +# 録画を書くホスト側のディレクトリ。移行で録画を移すなら、移行元と同じマウントの下に置く +CARINA_RECORDINGS_DIR= + +# エンコード済みファイルを書くホスト側のディレクトリ。ライブラリと一緒に増える +CARINA_ENCODES_DIR= + +# OIDC の client secret を暗号化する鍵を置くホスト側のディレクトリ。データベースとは別に残す +CARINA_KEYS_DIR= + +# ブラウザからこのサーバに届くアドレス (https://tv.example.com など)。ID プロバイダに登録する redirect URI の元になる +CARINA_PUBLIC_ORIGIN= + +# Caddy が開くサイトのアドレス。公開のドメイン (tv.example.com) なら https で開き証明書を取る。LAN だけなら :80 のまま +PYXIS_SITE_ADDRESS=:80 + +# ホスト側で開く http と https のポート。80 と 443 がほかに使われているなら別の番号にする +PYXIS_HTTP_PORT=80 +PYXIS_HTTPS_PORT=443 + +# 録画をあとからエンコードするときの変換器。Software か Vaapi (GPU を渡したとき) +CARINA_ENCODINGS_PREFER=Software + +# ライブと録画再生をその場で変換するときの変換器。Software か Vaapi (GPU を渡したとき) +CARINA_TRANSCODING_PREFER=Software + +# VAAPI で使う GPU の描画ノード +CARINA_RENDER_NODE=/dev/dri/renderD128 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2fbc508..7b48e1e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -17,3 +17,74 @@ jobs: - uses: actions/checkout@v7 - run: pipx install yamllint==1.38.0 - run: yamllint --strict . + + compose-config: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: The .env a check needs, with every required value filled in + run: | + cat > .env < .env </` を開く。\ +最初の起動で `app` がローカルアカウントを作り、ユーザー名とパスワードをログに一度だけ書く。 + +```bash +docker compose logs app | grep 'Sign in as' +``` + +そのアカウントでログインしたら、設定の画面でパスワードを変える。\ +チャンネルのスキャンと受信の確認も設定の画面にある。 + +## 設定 + +`.env` の変数は次の 12 個。\ +空にできない 5 つを空のままにすると、compose は起動せずにその変数の名前を出す。 + +| 変数 | 用途 | 既定 | | --- | --- | --- | -| `driver` | `ghcr.io/newworldorg/carina` | 特権で動き、チューナーを占有して録画ファイルを書く | -| `app` | `ghcr.io/newworldorg/carina` | 番組表・予約・ライブ・エンコード・ライブラリの HTTP API | -| `vela` | `ghcr.io/newworldorg/vela` | ブラウザで開く画面 | -| `db` | `postgres` | データベース | -| `proxy` | `caddy` | 前段。`/api/*` を `app` に、それ以外を `vela` に振り分ける | +| `POSTGRES_PASSWORD` | データベースのパスワード | 必須 | +| `CARINA_DB_DIR` | データベースのデータを置くホスト側のディレクトリ | 必須 | +| `CARINA_RECORDINGS_DIR` | 録画を置くホスト側のディレクトリ。`driver` が書き、`app` は読むだけ | 必須 | +| `CARINA_ENCODES_DIR` | エンコード済みのファイルを置くホスト側のディレクトリ | 必須 | +| `CARINA_KEYS_DIR` | OIDC の client secret を暗号化する鍵を置くホスト側のディレクトリ | 必須 | +| `CARINA_PUBLIC_ORIGIN` | ブラウザから届くアドレス。OIDC でサインインするときに要る | 空 | +| `PYXIS_SITE_ADDRESS` | 前段の Caddy が開くサイトのアドレス。ドメインを書くと証明書を取って https で開く | `:80` | +| `PYXIS_HTTP_PORT` | ホストで開く http のポート | `80` | +| `PYXIS_HTTPS_PORT` | ホストで開く https のポート。TCP と UDP | `443` | +| `CARINA_ENCODINGS_PREFER` | あとからのエンコードに使う変換器。`Software` か `Vaapi` | `Software` | +| `CARINA_TRANSCODING_PREFER` | ライブと再生のその場の変換に使う変換器。`Software` か `Vaapi` | `Software` | +| `CARINA_RENDER_NODE` | VAAPI で使う GPU のノード | `/dev/dri/renderD128` | + +番組表を集める間隔や品質の閾値といった細かい設定は、Carina の README の `app` の節にある。\ +変えたいものがあれば、`compose.override.yml` を置いて `app` の `environment` に書く。\ +このファイルは `.gitignore` に入っている。 + +### GPU + +`examples/gpu-intel.yml` を重ねただけでは、変換はソフトウェアのまま動く。\ +GPU で変換させるには `CARINA_ENCODINGS_PREFER` と `CARINA_TRANSCODING_PREFER` を `Vaapi` にする。\ +GPU が 2 枚あるときは `CARINA_RENDER_NODE` で使う方を選ぶ。 + +### 外から開く + +家の外から開くには、ドメインをこの機械に向けてルータの 80 番と 443 番を通し、`.env` に 2 行を書く。 + +``` +PYXIS_SITE_ADDRESS=tv.example.com +CARINA_PUBLIC_ORIGIN=https://tv.example.com +``` + +Caddy が Let's Encrypt から証明書を取り、http で来た要求は https へ送る。\ +`CARINA_PUBLIC_ORIGIN` は OIDC の redirect URI の元になるため、ブラウザが使うアドレスと一致させる。 + +ホストの 80 番や 443 番をほかのものが使っているときは、`PYXIS_HTTP_PORT` と `PYXIS_HTTPS_PORT` を空いている番号に変える。\ +ドメインで https にするなら、ルータ側の 80 番と 443 番をその番号へ向ける。 + +### 前段を自分で持つ + +前段のプロキシを自分で持つなら、`proxy` を外して `app` と `vela` を直接受ける。 + +```yaml +# compose.override.yml +services: + proxy: + profiles: [unused] + app: + ports: ['127.0.0.1:8080:8080'] + vela: + ports: ['127.0.0.1:3000:3000'] +``` + +その前段には、同梱の `Caddyfile` がしているのと同じ 5 つを持たせる。 + +- `/api/*` を `app` の 8080 番へ、それ以外を `vela` の 3000 番へ送り、両方を同じオリジンで開く。オリジンが分かれるとブラウザが Cookie を送らない +- `/api/events` はサーバから流れ続ける応答なので、溜めずに流す +- `/api/live/ws` は WebSocket なので、切らずに通す +- `Range` ヘッダをそのまま通す。再生とシークに使う +- アクセスログを取るなら、パスを残さない。外部プレイヤー向けの URL は、パスに再生用のトークンを含む + +あわせて `app` の `environment` に `CARINA_KNOWN_NETWORKS` を足し、前段のアドレスの範囲を書く。\ +これが無いと `X-Forwarded-*` が読まれず、https で開いてもセッション Cookie に Secure が付かない。 + +## 更新 + +Pyxis の新しい版は、`compose.yml` のイメージの版を上げたリリースとして出る。\ +古い `app` を止めてからデータベースのスキーマを当て、新しい `app` を上げる。\ +`app` が動いたままスキーマを当てると、列や表を消す変更で古い `app` が壊れる。 + +```bash +git fetch --tags +git checkout v0.2.0 # 上げたい版のタグ +docker compose pull +docker compose stop app +docker compose up -d +``` + +`up -d` は `migrate` を先に走らせ、それが成功してから `app` を上げる。\ +`driver` の版が前と同じなら `driver` は作り直されず、録画は続く。 + +`driver` の版が変わる更新は、録画中とライブの視聴中を避ける。\ +録画中の `driver` は録画が終わるまで止まらず、その間は新しい `driver` も上がらない。\ +待つ上限は `driver.json` の `shutdownGraceHours` で、既定は 6 時間。\ +ライブの視聴は切れる。 + +`driver` の入れ替えだけを後に回すこともできる。 + +```bash +docker compose stop app +docker compose run --rm migrate +docker compose up -d --no-deps app vela proxy +``` + +`driver` が変わるかどうかと、列や表を消すスキーマの変更があるかどうかは、各リリースの本文にある。 + +## 戻し方 + +更新の前に、データベースと鍵の控えを取っておく。 + +```bash +docker compose exec db pg_dump -U carina -Fc carina > carina-$(date +%Y%m%d).dump +tar -C "$(grep ^CARINA_KEYS_DIR= .env | cut -d= -f2)" -cf keys-$(date +%Y%m%d).tar . +``` + +戻すときは、前の版のタグに戻してからデータベースを控えから入れ直す。 + +```bash +docker compose stop app +git checkout v0.1.0 +docker compose exec -T db dropdb -U carina carina +docker compose exec -T db createdb -U carina -O carina carina +docker compose exec -T db pg_restore -U carina -d carina < carina-20260101.dump +docker compose up -d +``` + +スキーマの変更が足すだけだった版なら、データベースはそのままで前の版の `app` を上げれば動く。\ +どちらかはリリースの本文に書いてある。\ +録画のファイルはどの版でも同じ場所にあり、戻しても消えない。 + +## 困ったとき -`driver` と `app` は Unix ドメインソケットで通信する。 -別のプロセスなので、`app` を入れ替えても進行中の録画は止まらない。 -`app` と `vela` は同じオリジンに置き、振り分けは前段の `proxy` が行う。 +```bash +docker compose ps +docker compose logs app +docker compose logs driver +``` -版は Pyxis のリリースタグで固定する。 -`latest` には頼らず、イメージの版はファイルに数字で書く。 +| 症状 | 見るところ | +| --- | --- | +| `proxy` が上がらず `address already in use` と出る | ホストの 80 番か 443 番をほかのものが使っている。`PYXIS_HTTP_PORT` と `PYXIS_HTTPS_PORT` を変える | +| `up` が変数の名前を出して止まる | `.env` のその変数が空 | +| `driver` のログに `No smart-card reader is attached` と出る | ホストで pcscd が動いていないか、カードリーダがつながっていない。`systemctl status pcscd` で確かめる | +| `driver` のログに `could not be opened` と出る | `examples/tuner-dvb.yml` の `devices` と `driver/driver.json` の `devicePath` が合っていない。ホストで `ls /dev/dvb` を見る | +| 録画がスクランブルされたまま | カードリーダにカードが入っていないか、pcscd がカードを見失っている。pcscd を起動し直す | +| ログインのパスワードが分からない | `docker compose logs app \| grep 'Sign in as'`。2 回目以降の起動では書かれない | +| 画面は開くがライブも再生も始まらない | 前段を自分で持っているなら、`/api/live/ws` と `Range` が通っているか。`CARINA_KNOWN_NETWORKS` も確かめる | -## 必要なもの +## 版 -- Docker と Docker Compose -- チューナー・カードリーダ・GPU のある Linux の機械 1 台 +| Pyxis | Carina app | Carina driver | Vela | PostgreSQL | Caddy | +| --- | --- | --- | --- | --- | --- | +| 未リリース | v1.0.0 | v1.0.0 | v1.0.0 | 18.6 | 2.11.7 | -チューナーが無くても、合成のチューナーで起動だけは試せる。 +イメージの版は `compose.yml` に数字で書いてあり、`latest` は使わない。\ +Pyxis の版は、その組で起動・録画・再生を確かめてから付ける。 ## ライセンス -ライセンスは AGPL-3.0-only で、著作権者は NewWorldOrg である。 -詳細は `LICENSE` にある。 +AGPL-3.0-only。著作権者は NewWorldOrg で、全文は `LICENSE` にある。 -Pyxis が置いているのは設定だけである。 -各イメージに同梱した他のソフトウェアとそのライセンスは、イメージの中の `/usr/share/doc/carina/` と `/usr/share/doc/vela/` にある。 -PostgreSQL と Caddy は公式のイメージをそのまま引く。 +Pyxis が持つのは設定だけで、ソフトウェアは各イメージの側にある。\ +イメージに同梱した他のソフトウェアとそのライセンスは、イメージの中の `/usr/share/doc/carina/` と `/usr/share/doc/vela/` で読める。\ +PostgreSQL と Caddy は公式のイメージをそのまま使う。 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..1e66766 --- /dev/null +++ b/compose.yml @@ -0,0 +1,167 @@ +x-logging: &logging + driver: json-file + options: + max-size: 20m + max-file: '5' + +services: + db: + image: postgres:18.6-alpine + logging: *logging + restart: unless-stopped + environment: + POSTGRES_DB: carina + POSTGRES_USER: carina + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + volumes: + - ${CARINA_DB_DIR:?}:/var/lib/postgresql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U carina -d carina'] + interval: 5s + timeout: 3s + retries: 10 + start_period: 1m + + # app は uid 10001 で動くので、app が書く置き場の所有者をその番号にそろえてから app を上げる (中のファイルには触らない) + prepare: + image: ghcr.io/newworldorg/carina:app-v1.0.0 + logging: *logging + restart: 'no' + entrypoint: ['chown', '10001:10001', '/var/lib/carina/keys', '/srv/encodes', '/srv/thumbnails', '/srv/captions'] + volumes: + - ${CARINA_KEYS_DIR:?}:/var/lib/carina/keys + - ${CARINA_ENCODES_DIR:?}:/srv/encodes + - thumbnails:/srv/thumbnails + - captions:/srv/captions + + migrate: + image: ghcr.io/newworldorg/carina:app-v1.0.0 + logging: *logging + restart: 'no' + environment: + CARINA_ROLE: migrate + ConnectionStrings__Carina: Host=db;Port=5432;Database=carina;Username=carina;Password=${POSTGRES_PASSWORD:?} + depends_on: + db: + condition: service_healthy + + app: + image: ghcr.io/newworldorg/carina:app-v1.0.0 + logging: *logging + restart: unless-stopped + environment: + CARINA_ROLE: app + ConnectionStrings__Carina: Host=db;Port=5432;Database=carina;Username=carina;Password=${POSTGRES_PASSWORD:?} + CARINA_DATA_PROTECTION_KEYS: /var/lib/carina/keys + CARINA_PUBLIC_ORIGIN: ${CARINA_PUBLIC_ORIGIN:-} + # proxy はこの compose の網にいる。networks の subnet と同じ値にする + CARINA_KNOWN_NETWORKS: 172.30.0.0/24 + Integrity__OutputRoots: primary=/srv/recordings + Thumbnails__WrittenTo: /srv/thumbnails + Captions__WrittenTo: /srv/captions + Encodings__OutputRoots: encodes=/srv/encodes + Encodings__Prefer: ${CARINA_ENCODINGS_PREFER:-Software} + Transcoding__Prefer: ${CARINA_TRANSCODING_PREFER:-Software} + Machine__RenderNode: ${CARINA_RENDER_NODE:-/dev/dri/renderD128} + volumes: + - driver-run:/run/carina + - ${CARINA_RECORDINGS_DIR:?}:/srv/recordings:ro + - ${CARINA_ENCODES_DIR:?}:/srv/encodes + - ${CARINA_KEYS_DIR:?}:/var/lib/carina/keys + - thumbnails:/srv/thumbnails + - captions:/srv/captions + healthcheck: + test: + - CMD + - bash + - -c + - "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /api/health HTTP/1.0\\r\\nHost: app\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '" + interval: 30s + timeout: 5s + retries: 3 + start_period: 2m + start_interval: 5s + depends_on: + db: + condition: service_healthy + prepare: + condition: service_completed_successfully + migrate: + condition: service_completed_successfully + driver: + condition: service_started + + driver: + image: ghcr.io/newworldorg/carina:driver-v1.0.0 + logging: *logging + # 要求による停止は 0、それ以外は 70 で終わるので on-failure は使わない + restart: unless-stopped + privileged: true + # 録画中に止められると録画の終わり (上限 shutdownGraceHours、既定 6 時間) まで待つ。それより長くする + stop_grace_period: 7h + environment: + CARINA_ROLE: driver + CARINA_DRIVER_CONFIG: /etc/carina/driver.json + volumes: + - ./driver/driver.json:/etc/carina/driver.json:ro + - driver-run:/run/carina + - ${CARINA_RECORDINGS_DIR:?}:/srv/recordings + healthcheck: + test: ['CMD', '/opt/carina/driver/Carina.Driver', '--probe'] + interval: 30s + timeout: 15s + retries: 3 + start_period: 5m + start_interval: 10s + + vela: + image: ghcr.io/newworldorg/vela:v1.0.0 + logging: *logging + restart: unless-stopped + environment: + CARINA_API_BASE_URL: http://app:8080 + HOSTNAME: 0.0.0.0 + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:3000/login').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 1m + start_interval: 5s + + proxy: + image: caddy:2.11.7-alpine + logging: *logging + restart: unless-stopped + environment: + PYXIS_SITE_ADDRESS: ${PYXIS_SITE_ADDRESS:-:80} + ports: + - '${PYXIS_HTTP_PORT:-80}:80' + - '${PYXIS_HTTPS_PORT:-443}:443' + - '${PYXIS_HTTPS_PORT:-443}:443/udp' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + app: + condition: service_started + vela: + condition: service_started + +networks: + default: + ipam: + config: + - subnet: 172.30.0.0/24 + +volumes: + driver-run: + thumbnails: + captions: + caddy-data: + caddy-config: diff --git a/driver/driver.json b/driver/driver.json new file mode 100644 index 0000000..ac386e7 --- /dev/null +++ b/driver/driver.json @@ -0,0 +1,13 @@ +{ + "socketPath": "/run/carina/driver.sock", + "socketGroupId": 10001, + "outputRoots": [{ "name": "primary", "path": "/srv/recordings" }], + "shutdownGraceHours": 6, + "tuner": { + "backend": "fake" + }, + "devices": [ + { "id": "fake-terrestrial", "kind": "terrestrial", "enabled": true }, + { "id": "fake-satellite", "kind": "satellite", "enabled": true } + ] +} diff --git a/driver/examples/driver-dvb.json b/driver/examples/driver-dvb.json new file mode 100644 index 0000000..df8652a --- /dev/null +++ b/driver/examples/driver-dvb.json @@ -0,0 +1,13 @@ +{ + "socketPath": "/run/carina/driver.sock", + "socketGroupId": 10001, + "outputRoots": [{ "name": "primary", "path": "/srv/recordings" }], + "shutdownGraceHours": 6, + "tuner": { + "backend": "dvb" + }, + "devices": [ + { "id": "adapter0.frontend0", "kind": "terrestrial", "devicePath": "/dev/dvb/adapter0/frontend0", "enabled": true }, + { "id": "adapter1.frontend0", "kind": "satellite", "devicePath": "/dev/dvb/adapter1/frontend0", "enabled": false } + ] +} diff --git a/driver/examples/driver-usb.json b/driver/examples/driver-usb.json new file mode 100644 index 0000000..6f59245 --- /dev/null +++ b/driver/examples/driver-usb.json @@ -0,0 +1,12 @@ +{ + "socketPath": "/run/carina/driver.sock", + "socketGroupId": 10001, + "outputRoots": [{ "name": "primary", "path": "/srv/recordings" }], + "shutdownGraceHours": 6, + "tuner": { + "backend": "usb" + }, + "devices": [ + { "id": "usb1-1", "kind": "terrestrial", "devicePath": "/dev/bus/usb/001/002", "enabled": true } + ] +} diff --git a/examples/gpu-intel.yml b/examples/gpu-intel.yml new file mode 100644 index 0000000..0af02c2 --- /dev/null +++ b/examples/gpu-intel.yml @@ -0,0 +1,7 @@ +# Intel の GPU (VAAPI) を app に渡す。 +# 描画ノードの所有グループは、イメージの entrypoint が装置から読んで app に足すので group_add は要らない。 +# 渡しただけでは変換はソフトウェアのままなので、.env で CARINA_ENCODINGS_PREFER と CARINA_TRANSCODING_PREFER を Vaapi にする +services: + app: + devices: + - /dev/dri:/dev/dri diff --git a/examples/tuner-dvb.yml b/examples/tuner-dvb.yml new file mode 100644 index 0000000..3f22487 --- /dev/null +++ b/examples/tuner-dvb.yml @@ -0,0 +1,9 @@ +# Linux DVB のチューナーと B-CAS カード (ホストの pcscd) を driver に渡す。 +# チューナーが増えたら devices の行を足し、driver/driver.json の devices にも同じ装置を書く +services: + driver: + devices: + - /dev/dvb/adapter0:/dev/dvb/adapter0 + volumes: + # pcscd は起動し直すたびにソケットを作り直すので、ファイルではなくディレクトリごと渡す + - /run/pcscd:/run/pcscd:ro diff --git a/examples/tuner-usb.yml b/examples/tuner-usb.yml new file mode 100644 index 0000000..31a75db --- /dev/null +++ b/examples/tuner-usb.yml @@ -0,0 +1,6 @@ +# USB の地上デジタルチューナー (CECH-ZD1) を driver に渡す。 +# 挿し直すとノードの番号が変わるので、ノードではなく /dev/bus/usb をディレクトリごと渡す +services: + driver: + devices: + - /dev/bus/usb:/dev/bus/usb