Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
138 changes: 138 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
101 changes: 0 additions & 101 deletions .github/workflows/docker-build.yml

This file was deleted.

7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`).

Expand All @@ -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/<owner>/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
Expand Down
20 changes: 18 additions & 2 deletions app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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),
)
10 changes: 8 additions & 2 deletions app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading