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
30 changes: 30 additions & 0 deletions .github/releases/v2.0.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Blockbusterr v2.0.1

This patch fixes the broken connection setup workflow in v2.0.0. I am sorry for the frustrating first experience, and thank you to the users who reported it.

## Fixes

- Save changes works repeatedly without reloading the page.
- Radarr and Sonarr can load profiles and root folders using newly entered credentials before saving.
- Saved API keys stay hidden while connections correctly show as configured. Retesting no longer requires retyping saved keys.
- Temporary connection failures preserve selected profiles and folders, and show the actual error.
- Changes typed while a save is in progress remain marked as unsaved.

The previous empty key fields did not by themselves mean saved keys were deleted. Changes that were never successfully saved still need to be entered again.

## Update from v2.0.0

Keep your existing data volume. Set your image to `ghcr.io/mahcks/blockbusterr:v2.0.1`, pull it, and recreate the container. The stable `latest` tag also receives this patch.

For Docker Compose, after updating the image tag:

```bash
docker compose pull
docker compose up -d
```

Reload Settings, verify your connections, and save any unfinished setup. There is no configuration migration required from v2.0.0 and no need to delete your installation or data.

New desktop and mobile browser checks cover setup from empty configuration, testing, loading options, saving twice, and returning to saved settings. The release pipeline now runs the browser suite against the candidate image before publishing it.

Upgrading from v1? Follow the [v2 upgrade guide](https://blockbusterr.dev/getting-started/upgrading-to-v2/).
5 changes: 5 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ jobs:
- name: Smoke runtime image
run: scripts/test-container-smoke.sh blockbusterr:release-candidate

- name: Verify fresh-install settings in the release image
run: |
npx playwright install --with-deps chromium
BLOCKBUSTERR_BROWSER_IMAGE=blockbusterr:release-candidate npm run test:browser

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to Blockbusterr are documented here. The project follows sem

## [Unreleased]

## [2.0.1] - 2026-09-12

### Fixed

- Settings can save successive changes without reloading the page.
- Radarr and Sonarr profiles and root folders load with the credentials entered during setup, before saving.
- Saved hidden credentials correctly count as configured and can be retested without re-entering keys.
- Failed option loading preserves saved defaults and displays the server error.
- Edits made during a save remain marked as unsaved.

### Validation

- Added desktop and mobile browser coverage for empty configuration, connection testing, two saves, navigation, saved credentials, and unavailable delivery services.
- Release publication now requires the browser suite to pass against the candidate Docker image.

## [2.0.0] - 2026-09-12

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Blockbusterr

> [!IMPORTANT]
> **Blockbusterr v2.0.0** introduces reusable rules, preview-first jobs, and delivery safeguards. `latest` follows stable releases; `latest-beta` follows prereleases. Read the [documentation](https://blockbusterr.dev/) and [release notes](https://github.com/Mahcks/blockbusterr/releases/tag/v2.0.0). Upgrading from v1? Back up your complete data directory and follow the [upgrade guide](https://blockbusterr.dev/getting-started/upgrading-to-v2/).
> **Blockbusterr v2.0.1** fixes connection setup and saving settings in v2. `latest` follows stable releases; `latest-beta` follows prereleases. Read the [documentation](https://blockbusterr.dev/) and [release notes](https://github.com/Mahcks/blockbusterr/releases/tag/v2.0.1). Upgrading from v1? Back up your complete data directory and follow the [upgrade guide](https://blockbusterr.dev/getting-started/upgrading-to-v2/).

**Automated media discovery with rule-based decision making for self-hosted libraries.**

Expand Down Expand Up @@ -82,7 +82,7 @@ docker run -d \
--restart unless-stopped \
-p 9090:9090 \
-v blockbusterr-data:/app/data \
ghcr.io/mahcks/blockbusterr:v2.0.0
ghcr.io/mahcks/blockbusterr:v2.0.1
```

Open `http://localhost:9090`, connect one discovery provider and one delivery target, then create and preview a job.
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
blockbusterr:
image: ghcr.io/mahcks/blockbusterr:v2.0.0
image: ghcr.io/mahcks/blockbusterr:v2.0.1
container_name: blockbusterr
restart: unless-stopped
ports:
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/getting-started/deployment-platforms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Blockbusterr uses the same container on every compatible platform. The platform

| Setting | Value |
| --- | --- |
| Image | `ghcr.io/mahcks/blockbusterr:v2.0.0` |
| Pinned release tag | `ghcr.io/mahcks/blockbusterr:v2.0.0` |
| Image | `ghcr.io/mahcks/blockbusterr:v2.0.1` |
| Pinned release tag | `ghcr.io/mahcks/blockbusterr:v2.0.1` |
| Container port | `9090/tcp` |
| Persistent container path | `/app/data` |
| Required environment variables | None |
Expand All @@ -30,7 +30,7 @@ Use this on a Linux server, Docker Desktop, or any platform that accepts a Compo
```yaml
services:
blockbusterr:
image: ghcr.io/mahcks/blockbusterr:v2.0.0
image: ghcr.io/mahcks/blockbusterr:v2.0.1
container_name: blockbusterr
ports:
- "9090:9090"
Expand All @@ -55,7 +55,7 @@ Remove `BLOCKBUSTERR_DRY_RUN` after you have previewed a job and are ready to pe

<Steps>
1. Open **Docker**, select **Add Container**, and switch to the advanced view if needed.
2. Set the name to `blockbusterr` and the repository to `ghcr.io/mahcks/blockbusterr:v2.0.0`.
2. Set the name to `blockbusterr` and the repository to `ghcr.io/mahcks/blockbusterr:v2.0.1`.
3. Map a free host port, normally `9090`, to container port `9090` using TCP.
4. Map `/mnt/user/appdata/blockbusterr` on the host to `/app/data` in the container with read/write access.
5. Add `TZ` using your IANA timezone. Add `BLOCKBUSTERR_DRY_RUN=true` while verifying your setup.
Expand All @@ -70,7 +70,7 @@ An official Community Applications template is not currently published. Adding t
<TabItem label="TrueNAS SCALE">

<Steps>
1. Open **Apps**, choose the option to install a custom app, and use `ghcr.io/mahcks/blockbusterr:v2.0.0` as the image.
1. Open **Apps**, choose the option to install a custom app, and use `ghcr.io/mahcks/blockbusterr:v2.0.1` as the image.
2. Expose container port `9090` over TCP using an available host port.
3. Add host-path storage for a dataset such as `/mnt/<pool>/apps/blockbusterr`, mounted inside the container at `/app/data` with write access.
4. Add `TZ` and, while verifying your setup, `BLOCKBUSTERR_DRY_RUN=true` as environment variables.
Expand Down
18 changes: 9 additions & 9 deletions docs/src/content/docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Configure any one supported discovery provider: TMDB, Simkl, Trakt, or MDBList.
</Aside>

<Aside type="caution" title="Installing v2">
Pin `ghcr.io/mahcks/blockbusterr:v2.0.0` for this release. `latest` follows stable releases and can move across major versions; `latest-beta` follows prereleases. Before upgrading from v1, back up the complete data directory and follow the [upgrade guide](/getting-started/upgrading-to-v2/).
Pin `ghcr.io/mahcks/blockbusterr:v2.0.1` for this release. `latest` follows stable releases and can move across major versions; `latest-beta` follows prereleases. Before upgrading from v1, back up the complete data directory and follow the [upgrade guide](/getting-started/upgrading-to-v2/).
</Aside>

<Aside type="tip" title="Using a NAS or container UI">
Expand All @@ -49,7 +49,7 @@ See [Deployment platforms](/getting-started/deployment-platforms/) for field-by-
1. Pull the release image:

```bash
docker pull ghcr.io/mahcks/blockbusterr:v2.0.0
docker pull ghcr.io/mahcks/blockbusterr:v2.0.1
```

2. Run the container:
Expand All @@ -60,7 +60,7 @@ See [Deployment platforms](/getting-started/deployment-platforms/) for field-by-
-p 9090:9090 \
-v blockbusterr-data:/app/data \
-e TZ=America/New_York \
ghcr.io/mahcks/blockbusterr:v2.0.0
ghcr.io/mahcks/blockbusterr:v2.0.1
```

3. Access the web UI at `http://localhost:9090`
Expand All @@ -83,7 +83,7 @@ Use `docker logs blockbusterr -f` to view live logs and verify startup.
```yaml
services:
blockbusterr:
image: ghcr.io/mahcks/blockbusterr:v2.0.0
image: ghcr.io/mahcks/blockbusterr:v2.0.1
container_name: blockbusterr
ports:
- "9090:9090"
Expand Down Expand Up @@ -124,7 +124,7 @@ networks:

services:
blockbusterr:
image: ghcr.io/mahcks/blockbusterr:v2.0.0
image: ghcr.io/mahcks/blockbusterr:v2.0.1
container_name: blockbusterr
networks:
- media
Expand Down Expand Up @@ -242,7 +242,7 @@ docker run -d \
-e DATA_DIR=/app/data \
-e TZ=Europe/London \
-v $(pwd)/data:/app/data \
ghcr.io/mahcks/blockbusterr:v2.0.0
ghcr.io/mahcks/blockbusterr:v2.0.1
```

## Health Check
Expand All @@ -257,7 +257,7 @@ Expected response:

```json
{
"version": "v2.0.0",
"version": "v2.0.1",
"uptime": "1785518792000"
}
```
Expand All @@ -268,7 +268,7 @@ Expected response:
<TabItem label="Docker" icon="seti:docker">

```bash
docker pull ghcr.io/mahcks/blockbusterr:v2.0.0
docker pull ghcr.io/mahcks/blockbusterr:v2.0.1
docker stop blockbusterr
docker rm blockbusterr
# Then run the original docker run command
Expand All @@ -277,7 +277,7 @@ docker rm blockbusterr
</TabItem>
<TabItem label="Docker Compose" icon="seti:yml">

Update `image:` in your Compose file to the intended version before pulling. For this release, use `ghcr.io/mahcks/blockbusterr:v2.0.0`.
Update `image:` in your Compose file to the intended version before pulling. For this release, use `ghcr.io/mahcks/blockbusterr:v2.0.1`.

```bash
docker compose pull blockbusterr
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/getting-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ docker run -d \
-p 9090:9090 \
-v blockbusterr-data:/app/data \
--restart unless-stopped \
ghcr.io/mahcks/blockbusterr:v2.0.0
ghcr.io/mahcks/blockbusterr:v2.0.1
```

Open `http://localhost:9090`. To enable owner authentication, add `-e BLOCKBUSTERR_AUTH_TOKEN="replace-with-at-least-32-random-characters"` and sign in as `blockbusterr`.
Expand Down
31 changes: 31 additions & 0 deletions internal/rest/v1/routes/connection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package routes

import (
"strings"

"github.com/gofiber/fiber/v2"
)

// connectionCredentials accepts draft credentials without saving them. A hidden
// saved key may only be reused for the same server, never a replacement URL.
func connectionCredentials(c *fiber.Ctx, savedURL, savedKey string) (string, string, error) {
c.Set(fiber.HeaderCacheControl, "no-store")
if c.Method() != fiber.MethodPost {
return savedURL, savedKey, nil
}
var request struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
}
if err := c.BodyParser(&request); err != nil {
return "", "", fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
url, key := strings.TrimSpace(request.URL), strings.TrimSpace(request.APIKey)
if key == "" && url == savedURL {
key = savedKey
}
if url == "" || key == "" {
return "", "", fiber.NewError(fiber.StatusBadRequest, "Server URL and API key are required; enter a key when changing servers")
}
return url, key, nil
}
72 changes: 72 additions & 0 deletions internal/rest/v1/routes/connection_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package routes
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -44,3 +46,73 @@ func TestRadarrValidationAcceptsCredentialsInPostBody(t *testing.T) {
t.Fatalf("Cache-Control = %q", cache)
}
}

func TestConnectionOptionsUseDraftAndSavedCredentials(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-secret" || r.URL.RawQuery != "" {
t.Error("credentials must use the API key header")
}
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "qualityprofile") {
_, _ = w.Write([]byte(`[{"id":7,"name":"HD"}]`))
} else if strings.Contains(r.URL.Path, "rootfolder") {
_, _ = w.Write([]byte(`[{"id":1,"path":"/media"}]`))
} else {
_, _ = w.Write([]byte(`{"version":"1.0.0"}`))
}
}))
defer upstream.Close()
for _, saved := range []bool{false, true} {
cfg := &config.Config{}
if saved {
cfg.Radarr.URL, cfg.Radarr.APIKey = upstream.URL, "test-secret"
cfg.Sonarr.URL, cfg.Sonarr.APIKey = upstream.URL, "test-secret"
cfg.Jellyseerr.URL, cfg.Jellyseerr.APIKey = upstream.URL, "test-secret"
}
gctx := global.New(context.Background(), cfg, nil, "test", "test", nil)
app := fiber.New()
routes := NewRouteGroup(gctx)
RegisterRadarrRoutes(routes, app.Group("/v1"))
RegisterSonarrRoutes(routes, app.Group("/v1"))
routes.RegisterJellyseerrRoutes(app.Group("/v1"))
for _, service := range []string{"radarr", "sonarr", "jellyseerr"} {
paths := []string{"validate"}
if service != "jellyseerr" {
paths = append(paths, "quality-profiles", "root-folders")
}
for _, path := range paths {
for _, scenario := range []struct {
name, url, key string
status int
}{
{"draft", upstream.URL, "test-secret", 200},
{"hidden", upstream.URL, "", map[bool]int{true: 200, false: 400}[saved]},
{"different server", "http://127.0.0.1:1", "", 400},
} {
t.Run(fmt.Sprintf("%s/%s/saved=%t/%s", service, path, saved, scenario.name), func(t *testing.T) {
body, err := json.Marshal(map[string]string{"url": scenario.url, "api_key": scenario.key})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/"+service+"/"+path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != scenario.status {
t.Fatalf("status=%d want=%d", resp.StatusCode, scenario.status)
}
if resp.Header.Get("Cache-Control") != "no-store" {
t.Error("missing no-store")
}
})
}
}
}
if !saved && (cfg.Radarr.APIKey != "" || cfg.Sonarr.APIKey != "" || cfg.Jellyseerr.APIKey != "") {
t.Error("draft request persisted credentials")
}
}
}
4 changes: 3 additions & 1 deletion internal/rest/v1/routes/dynamic_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ func AddDynamicJobsRoutes(router fiber.Router, gctx global.Context) {
if err := c.BodyParser(&request); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
apiKey = request.APIKey
if request.APIKey != "" {
apiKey = request.APIKey
}
}
c.Set(fiber.HeaderCacheControl, "no-store")
if apiKey == "" {
Expand Down
30 changes: 4 additions & 26 deletions internal/rest/v1/routes/jellyseerr.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,10 @@ import (
func (rg *RouteGroup) RegisterJellyseerrRoutes(group fiber.Router) {
// Validate Jellyseerr connection
validate := func(c *fiber.Ctx) error {
var url, apiKey string
if c.Method() == fiber.MethodPost {
var request struct {
URL string `json:"url"`
APIKey string `json:"api_key"`
}
if err := c.BodyParser(&request); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body", "connected": false})
}
url, apiKey = request.URL, request.APIKey
} else {
// Normal mode, use saved config
cfg := rg.gctx.Config()
url = cfg.Jellyseerr.URL
apiKey = cfg.Jellyseerr.APIKey

if url == "" || apiKey == "" {
return c.Status(400).JSON(fiber.Map{
"connected": false,
"error": "Jellyseerr URL or API key is not configured",
})
}
}
c.Set(fiber.HeaderCacheControl, "no-store")
if url == "" || apiKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Jellyseerr URL and API key are required", "connected": false})
cfg := rg.gctx.Config()
url, apiKey, err := connectionCredentials(c, cfg.Jellyseerr.URL, cfg.Jellyseerr.APIKey)
if err != nil || url == "" || apiKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Server URL and API key are required; enter a key when changing servers", "connected": false})
}

jellyseerrClient := integrations.NewJellyseerr(integrations.JellyseerrConfig{
Expand Down
Loading
Loading