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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## 2.12.1 — "Update Now" actually requests an update

`POST /api/admin/update` wrote the literal string `requested at <timestamp>` into
the signal file. The updater added in 2.12.0 reads `target_version` out of that
file as JSON and refuses anything that is not semver — correctly, since the tag
comes from the GitHub API and reaches `docker pull`. So the button parsed to an
empty target and was refused every time: **"Update Now" silently did nothing.**

It now resolves the latest release from GitHub itself (rather than trusting the
caller with a tag), writes the same JSON shape `tasks/auto_update.py` writes, and
returns `up_to_date` instead of queueing a no-op when you are already current.
`?force=true` re-applies the current version to recover a half-applied update.

### Added
- `GET /api/admin/update-result` — the outcome of the last update, including
rollbacks, so "Update Now" can report what happened instead of being a button
that reports nothing.

### Fixed
- `scripts/deploy-to-nas.sh` extracted a tar, which adds and overwrites but never
DELETES. Files removed upstream lingered on the remote forever — the dead
services deleted in 2.11.0 were still on the NAS afterwards. It now prunes
tracked-but-absent files first, so a deploy mirrors the repo. Host-local files
(`.env`, `docker-compose.override.yml`, logs) are explicitly protected.
- `_read_version()` replaces three inlined copies of the same VERSION read in
`routes/admin.py`, one of which had drifted.
- Example Plex URLs in docstrings no longer use a real LAN address.


## 2.12.0 — Auto-update actually works

`auto_update_enabled` has existed for a while. It could never have worked. Three
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.12.0
2.12.1
17 changes: 17 additions & 0 deletions scripts/deploy-to-nas.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")

echo "[deploy] Version: $VERSION (SHA: $GIT_SHA)"
echo "[deploy] Syncing files to remote host..."
# tar-extract ADDS and overwrites but never DELETES, so files removed upstream
# linger on the remote forever. Dead code deleted in 2.11.0 was still sitting on
# the NAS afterwards. Prune tracked-but-now-absent files first so the deployment
# actually mirrors the repo. Only touches paths git knows about, so .env,
# docker-compose.override.yml and other host-local files are never at risk.
KEEP_LOCAL='^(\.env|docker-compose\.override\.yml|deploy-history\.log|logs/)'
git ls-files > /tmp/waxflow-manifest.txt 2>/dev/null || : > /tmp/waxflow-manifest.txt
if [ -s /tmp/waxflow-manifest.txt ]; then
ssh "$REMOTE_HOST" "cat > /tmp/waxflow-manifest.txt" < /tmp/waxflow-manifest.txt
ssh "$REMOTE_HOST" "cd $REMOTE_PATH && \
find . -type f -not -path './.git/*' -not -path './node_modules/*' \
-not -path './.next/*' -not -path '*/__pycache__/*' -not -path '*/@eaDir/*' \
-not -path './logs/*' -printf '%P\n' 2>/dev/null \
| grep -Ev \"\$KEEP_LOCAL\" \
| grep -vxFf /tmp/waxflow-manifest.txt \
| while read -r stale; do echo \"[deploy] pruning \$stale\"; rm -f -- \"\$stale\"; done" || true
fi
tar czf - --exclude='.next' --exclude='node_modules' --exclude='.env' --exclude='*.db' --exclude='.git' --exclude='__pycache__' . \
| ssh "$REMOTE_HOST" "cd $REMOTE_PATH && tar xzf -"

Expand Down
119 changes: 99 additions & 20 deletions sync-api/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@
SENSITIVE_KEYS = {"spotify_access_token", "spotify_refresh_token", "spotify_token_expiry"}


def _read_version(default: str = "unknown") -> str:
"""Running version, from the VERSION file baked in at build time.

Single source of truth: three call sites used to inline this, and one of them
had drifted to a hardcoded string.
"""
try:
path = Path("/app/VERSION")
if path.exists():
return path.read_text().strip()
except Exception:
pass
return os.environ.get("VERSION", default)


@router.get("/settings")
async def get_settings():
try:
Expand Down Expand Up @@ -132,17 +147,94 @@ async def health_check():


@router.post("/admin/update")
async def trigger_update():
"""Create a signal file that the auto-update cron script watches for.
Writes to the Docker volume at /app/data/ so it persists and is visible to host scripts.
async def trigger_update(force: bool = False):
"""Request an immediate update — the "Update Now" button.

Writes the signal file that waxflow-updater watches. The payload MUST be the
same JSON shape tasks/auto_update.py writes: the updater reads target_version
out of it and refuses anything that is not semver. This endpoint used to write
the literal string "requested at <ts>", which the updater would (correctly)
reject every single time — so "Update Now" silently did nothing.

Resolves the target from GitHub rather than trusting the caller, so a request
cannot point the updater at an arbitrary tag. Pass force=true to re-apply the
current version (useful to recover a half-applied update).
"""
import httpx

current = _read_version()
signal_path = Path("/app/data/.update-requested")

try:
signal_path.write_text(f"requested at {time.time()}")
return {"status": "ok", "message": "Update requested. The auto-update cron will pick this up."}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
"https://api.github.com/repos/rancur/waxflow/releases/latest"
)
if resp.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"GitHub returned {resp.status_code} while checking for a release",
)
data = resp.json()
latest = (data.get("tag_name") or "").lstrip("v")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Could not reach GitHub: {e}")

if not latest:
raise HTTPException(status_code=502, detail="GitHub returned no release tag")

if not force and not _is_newer(latest, current):
return {
"status": "up_to_date",
"message": f"Already on {current}; latest release is {latest}.",
"current_version": current,
"latest_version": latest,
}

try:
signal_path.write_text(
json.dumps(
{
"requested_at": time.time(),
"current_version": current,
"target_version": latest,
"triggered_by": "manual",
}
)
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

return {
"status": "ok",
"message": f"Update to {latest} requested. waxflow-updater applies it within a minute.",
"current_version": current,
"target_version": latest,
"release_url": data.get("html_url"),
}


@router.get("/admin/update-result")
async def update_result():
"""Outcome of the last update the updater applied.

The updater writes this after each attempt, including rollbacks, so the UI can
say what happened instead of leaving "Update Now" as a button that reports
nothing back.
"""
result_path = Path("/app/data/.update-result")
pending = Path("/app/data/.update-requested").exists()
if not result_path.exists():
return {"status": "none", "pending": pending}
try:
data = json.loads(result_path.read_text())
data["pending"] = pending
return data
except Exception:
return {"status": "unknown", "pending": pending}


@router.get("/admin/export")
async def export_sync_report(format: str = "json"):
Expand Down Expand Up @@ -232,14 +324,7 @@ async def get_analyze_stats():

@router.get("/admin/version", response_model=VersionResponse)
async def get_version():
# Read version from VERSION file (baked into image at build time)
version = None
version_path = Path("/app/VERSION")
try:
if version_path.exists():
version = version_path.read_text().strip()
except Exception:
pass
version = _read_version(default=None) or None

# Read git SHA from env var (set as build arg)
git_sha = os.environ.get("GIT_SHA") or None
Expand All @@ -254,13 +339,7 @@ async def check_update():
"""Check GitHub for newer releases."""
import httpx

current = "unknown"
version_path = Path("/app/VERSION")
try:
if version_path.exists():
current = version_path.read_text().strip()
except Exception:
pass
current = _read_version()

try:
async with httpx.AsyncClient(timeout=10) as client:
Expand Down
2 changes: 1 addition & 1 deletion sync-worker/tasks/plex_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PlexClient:
"""Minimal Plex Media Server client over httpx.

Args:
base_url: e.g. ``http://192.168.1.221:32400``.
base_url: e.g. ``http://nas.local:32400``.
token: the ``X-Plex-Token`` (self-generated from the server's
Preferences.xml; stored in 1Password + app_config, never in git).
timeout: per-request timeout in seconds.
Expand Down
2 changes: 1 addition & 1 deletion sync-worker/tasks/plex_sync.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Plex / Plexamp mirror (WaxFlow v3 — Feature 4).

Mirrors what WaxFlow already syncs into Lexicon over to the Plex server that runs
ON the NAS (``http://192.168.1.221:32400``) and reads the SAME ``/volume1/music``
ON the NAS (``http://<nas>:32400``) and reads the SAME ``/volume1/music``
tree, so the monthly ``MM. Month YYYY`` playlists show up in Plexamp.

Three responsibilities, all idempotent:
Expand Down
Loading