diff --git a/.env.example b/.env.example index 1f80f95..86cddc7 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= DATABASE_URL= SITE_PASSWORD= +# SERVER_HOST=127.0.0.1 +# SERVER_PORT=8000 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d7fbe10 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,138 @@ +name: Release (Manual) + +on: + workflow_dispatch: + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.version.outputs.new }} + is_prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: version + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])') + if [ -z "$version" ]; then + echo "::error::Could not parse version from pyproject.toml" + exit 1 + fi + tag="v$version" + echo "new=$tag" >> "$GITHUB_OUTPUT" + if [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Abort if tag already exists + run: | + if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.new }}" | grep -q .; then + echo "::error::Tag ${{ steps.version.outputs.new }} already exists on remote" + exit 1 + fi + + - name: Create draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + # Clean up any leftover draft from a previous failed run with the same version. + # A draft release doesn't create the git tag on the remote, so a stale draft is the + # only thing that can collide here (real published tags are caught by the step above). + if gh release view "${{ steps.version.outputs.new }}" --json isDraft --jq '.isDraft' 2>/dev/null | grep -qx true; then + echo "Deleting stale draft release ${{ steps.version.outputs.new }}" + gh release delete "${{ steps.version.outputs.new }}" --yes + fi + gh release create "${{ steps.version.outputs.new }}" \ + --draft \ + --target "${{ github.sha }}" \ + --title "Release ${{ steps.version.outputs.new }}" \ + --generate-notes \ + ${{ steps.version.outputs.prerelease == 'true' && '--prerelease' || '' }} + + docker-build: + needs: setup + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - runner: ubuntu-latest + arch: amd64 + platform: linux/amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + platform: linux/arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image (native ${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: ${{ matrix.platform }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.release_tag }}-${{ matrix.arch }} + ${{ needs.setup.outputs.is_prerelease == 'false' && format('{0}/{1}:latest-{2}', env.REGISTRY, env.IMAGE_NAME, matrix.arch) || '' }} + cache-from: type=gha + cache-to: type=gha,mode=max + + docker-manifest: + needs: [setup, docker-build] + runs-on: ubuntu-latest + steps: + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create versioned multi-arch manifest + run: | + docker buildx imagetools create \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.release_tag }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.release_tag }}-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.setup.outputs.release_tag }}-arm64 + + - name: Create latest multi-arch manifest + if: needs.setup.outputs.is_prerelease == 'false' + run: | + docker buildx imagetools create \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 + + create-release: + needs: [setup, docker-manifest] + runs-on: ubuntu-latest + steps: + - name: Publish release (remove draft, creates the tag) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: gh release edit "${{ needs.setup.outputs.release_tag }}" --draft=false diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index 036202b..0000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Build Docker Image - -on: - release: - types: [created] - workflow_dispatch: - inputs: - tag: - description: 'Docker image tag (optional, defaults to timestamp)' - required: false - type: string - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - meta: - if: github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'v') - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.tag.outputs.tag }} - steps: - - name: Determine tag - id: tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT - elif [ -n "${{ inputs.tag }}" ]; then - echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "tag=$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT - fi - - build: - needs: meta - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - matrix: - include: - - runner: ubuntu-latest - arch: amd64 - platform: linux/amd64 - - runner: ubuntu-24.04-arm - arch: arm64 - platform: linux/arm64 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push Docker image (native ${{ matrix.arch }}) - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: ${{ matrix.platform }} - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.meta.outputs.tag }}-${{ matrix.arch }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }} - cache-from: type=gha - cache-to: type=gha,mode=max - - manifest: - needs: [meta, build] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create multi-arch manifest - run: | - docker buildx imagetools create \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.meta.outputs.tag }} \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.meta.outputs.tag }}-amd64 \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.meta.outputs.tag }}-arm64 - - docker buildx imagetools create \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64 diff --git a/Dockerfile b/Dockerfile index dd2b122..b001424 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,11 @@ WORKDIR /usr/src/app COPY app ./app COPY --from=frontend-builder /build/frontend/dist ./frontend/dist +# Containers need to listen on all interfaces by default; both are overridable +# at run time (env or `--host`/`--port` on the CMD). +ENV SERVER_HOST=0.0.0.0 \ + SERVER_PORT=8000 + EXPOSE 8000 ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["s3player", "server"] diff --git a/README.md b/README.md index c144b6b..959b291 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Backend reads from environment (a `.env` at the repo root works): | `S3_SECRET_ACCESS_KEY` | S3 secret key | | `DATABASE_URL` | Postgres URL (`postgres://…`) | | `SITE_PASSWORD` | Single password protecting the app | +| `SERVER_HOST` | Bind address (default `127.0.0.1`) | +| `SERVER_PORT` | Bind port (default `8000`) | For Postgres you can either set `DATABASE_URL` directly or supply the discrete pieces (useful when injecting from a Kubernetes ConfigMap/Secret); if @@ -42,10 +44,15 @@ indexer once to populate `shows` and `episodes` from S3. ``` uv sync -uv run s3player server # serves on http://127.0.0.1:8000 -uv run s3player index # one-shot S3 → Postgres indexer +uv run s3player server # serves on http://127.0.0.1:8000 +uv run s3player server --reload # same, with auto-reload on code changes +uv run s3player index # one-shot S3 → Postgres indexer ``` +`server` binds `$SERVER_HOST:$SERVER_PORT`; `--host` / `--port` override the +environment. The Docker image defaults `SERVER_HOST=0.0.0.0` so the container +is reachable, and either variable can be overridden at run time. + API docs at `http://127.0.0.1:8000/docs` (also proxied through the dev server at `http://localhost:5173/docs`). @@ -57,6 +64,16 @@ bun install # first time only bun run dev # serves on http://localhost:5173, proxies /api → :8000 ``` +## Releases + +Releases are cut manually: bump `version` in `pyproject.toml`, merge to `main`, +then run the **Release (Manual)** workflow (`.github/workflows/build.yml`) from +the Actions tab. It derives the tag from `pyproject.toml` (`0.0.1` → `v0.0.1`), +opens a draft release, builds and pushes multi-arch images to +`ghcr.io//s3player`, then publishes the release — which is what creates +the git tag. Dispatching from a branch other than `main` marks the release as a +prerelease and skips the `latest` image tags. + ## Auth Visit `/login` and enter `SITE_PASSWORD`. An HMAC token is set as the diff --git a/app/cli.py b/app/cli.py index 9a64852..b19d1ce 100644 --- a/app/cli.py +++ b/app/cli.py @@ -4,7 +4,23 @@ def main() -> None: parser = argparse.ArgumentParser(prog="s3player") sub = parser.add_subparsers(dest="cmd", required=True) - sub.add_parser("server", help="Run the FastAPI server on :8000") + server_parser = sub.add_parser("server", help="Run the FastAPI server") + server_parser.add_argument( + "--host", + default=None, + help="Bind address (default: $SERVER_HOST, else 127.0.0.1)", + ) + server_parser.add_argument( + "--port", + type=int, + default=None, + help="Bind port (default: $SERVER_PORT, else 8000)", + ) + server_parser.add_argument( + "--reload", + action="store_true", + help="Reload on code changes (development only)", + ) index_parser = sub.add_parser("index", help="Index audio files from S3 into Postgres") index_parser.add_argument( "--overwrite", @@ -17,7 +33,7 @@ def main() -> None: if args.cmd == "server": from app.server import run - run() + run(host=args.host, port=args.port, reload=args.reload) elif args.cmd == "index": from app.indexer import run diff --git a/app/config.py b/app/config.py index 16b249f..6184a47 100644 --- a/app/config.py +++ b/app/config.py @@ -17,6 +17,8 @@ class Settings: s3_secret_access_key: str database_url: str site_password: str + server_host: str + server_port: int def _resolve_database_url() -> str: @@ -41,4 +43,6 @@ def get_settings() -> Settings: s3_secret_access_key=os.environ["S3_SECRET_ACCESS_KEY"], database_url=_resolve_database_url(), site_password=os.environ["SITE_PASSWORD"], + server_host=os.environ.get("SERVER_HOST") or "127.0.0.1", + server_port=int(os.environ.get("SERVER_PORT") or 8000), ) diff --git a/app/server.py b/app/server.py index bd9ab12..e7e6ec7 100644 --- a/app/server.py +++ b/app/server.py @@ -96,5 +96,11 @@ async def get_response(self, path: str, scope: Scope) -> Response: app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend") -def run() -> None: - uvicorn.run("app.server:app", host="127.0.0.1", port=8000, reload=True) +def run(host: str | None = None, port: int | None = None, *, reload: bool = False) -> None: + settings = get_settings() + uvicorn.run( + "app.server:app", + host=host if host is not None else settings.server_host, + port=port if port is not None else settings.server_port, + reload=reload, + ) diff --git a/pyproject.toml b/pyproject.toml index 796caa4..d614554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "s3player" -version = "0.0.0" +version = "0.0.1" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 8023873..a702140 100644 --- a/uv.lock +++ b/uv.lock @@ -517,7 +517,7 @@ wheels = [ [[package]] name = "s3player" -version = "0.0.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "asyncpg" },