Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f70df9f827 | ||
|
|
4ce47397a9 | ||
|
|
721154847e | ||
|
|
633d4f591f | ||
|
|
f5dd4462de | ||
|
|
31190657d8 | ||
|
|
ecfa056d4d | ||
|
|
f367eeaa9d | ||
|
|
270ad7a71b | ||
|
|
17212e9eb4 | ||
|
|
8f4b76a638 | ||
|
|
aeb8781c4e | ||
|
|
439c8625d5 | ||
|
|
88508b536b | ||
|
|
1d67c160b2 | ||
|
|
90bb3538c6 | ||
|
|
a687ef439c | ||
|
|
eaf4654c0a | ||
|
|
ca1c18bbbb | ||
|
|
68136c64c0 | ||
|
|
9f3e0b8cd3 | ||
|
|
e46c6bcccf | ||
|
|
bfdaed9365 | ||
|
|
237380b122 | ||
|
|
c27f9d484a | ||
|
|
b52a00df66 | ||
|
|
16005054eb | ||
|
|
b06a1adfe8 | ||
|
|
5593f7ce17 |
@@ -6,9 +6,20 @@
|
||||
**/build
|
||||
web/build
|
||||
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
# The Android client — built by its own job, never from this context. The APK
|
||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||
# here reads android/ sources.
|
||||
#
|
||||
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
|
||||
# that tree was deleted, leaving a comment describing an exclusion that was no
|
||||
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
|
||||
# has been entering the context and busting the `COPY . .` layer on every
|
||||
# Android-only change.
|
||||
android/
|
||||
|
||||
# Local `make build` output — an 18 MB binary the image never uses, since the
|
||||
# builder stage compiles its own.
|
||||
bin/
|
||||
|
||||
# Docs and IDE noise
|
||||
docs/
|
||||
@@ -26,5 +37,8 @@ docs/
|
||||
!.env.example
|
||||
|
||||
# CI workflow files don't need to ship in the image.
|
||||
.forgejo/
|
||||
.github/
|
||||
#
|
||||
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
|
||||
# Actions reads `.gitea/`, so the one directory that actually exists was the
|
||||
# one not excluded, and every workflow edit invalidated the context.
|
||||
.gitea/
|
||||
|
||||
@@ -80,15 +80,12 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action: it works on this forge since the runner moved to
|
||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||
# success while Gitea serves artifacts back only through the v4 API, and
|
||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
@@ -2,15 +2,71 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||
# tag is intentionally mutable — if a second release happens the same day,
|
||||
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
|
||||
# the same name gets overwritten. :latest is updated by every main push
|
||||
# AND every tag push, so it always reflects the newest blessed image.
|
||||
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
||||
#
|
||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||
# without a release ceremony. It is minted only on main, where rollback is
|
||||
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
||||
# per year, while on dev they would be one per push, forever, for a channel
|
||||
# whose entire contract is that it moves.
|
||||
#
|
||||
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
||||
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
||||
# a version tag nobody pinned while the rollback unit the rule names did not
|
||||
# exist here at all. Git and the build's own self-reported version answer
|
||||
# "which build is this"; a third name for the same thing is upkeep for a model
|
||||
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
|
||||
# of versioning for their update process."
|
||||
#
|
||||
# There is no :main either. :latest tracks main's tip with no gate between them
|
||||
# (rule 147), so a second name for the same image sends readers looking for a
|
||||
# distinction that does not exist.
|
||||
#
|
||||
# The dev channel exists so testing a build does not require shipping one.
|
||||
# Before it, the only way to get an APK onto a phone was to cut a release,
|
||||
# which made `main` the staging area by default. `:dev` carries its own
|
||||
# freshly-built APK, signed with the SAME key as release builds — a different
|
||||
# key cannot install over the stable app, so anyone crossing channels would
|
||||
# have to uninstall and lose their data.
|
||||
#
|
||||
# :dev is published ALONE, with no per-commit tag. A rolling channel is
|
||||
# rolling by definition; a commit-addressable image for it would be a
|
||||
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
|
||||
# forward.
|
||||
#
|
||||
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
|
||||
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
|
||||
# the family because the app and the server live in separate repos. Minstrel
|
||||
# is a monorepo — one push builds the APK and the image in the same run from
|
||||
# the same commit, so the channel cannot go stale against its own artifact.
|
||||
# The requirement is satisfied structurally; copying the mechanism would add
|
||||
# a moving part to fix a problem that does not exist here.
|
||||
#
|
||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
||||
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
||||
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
||||
# between what the tag says and what the APK reports, and nothing to look up
|
||||
# when minting one.
|
||||
#
|
||||
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
|
||||
# same-day second release is not a collision — HHMM makes every tag unique
|
||||
# by construction, so the answer is simply another tag.
|
||||
#
|
||||
# This block used to say the opposite: that the per-day tag was
|
||||
# "intentionally mutable" and that a same-day re-cut should
|
||||
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
|
||||
# rulebook now forbids outright, and it has incidents behind it — moving a
|
||||
# same-day tag forward once took a published release down with it. Anyone
|
||||
# installing from a tag is holding something the tag no longer points at,
|
||||
# which is a worse failure than an extra row in the tag list.
|
||||
#
|
||||
# :latest is updated by every main push AND every tag push, so it always
|
||||
# reflects the newest blessed image.
|
||||
#
|
||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
@@ -24,33 +80,37 @@ name: release
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
# AND the version sidecar published beside it — the recorded values, not
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
tags: ['v*']
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# Force-moving the per-day tag (or rapidly re-pushing to main) should
|
||||
# supersede the in-flight build — the operator explicitly wants the
|
||||
# later commit to win.
|
||||
# A rapid re-push to main should supersede the in-flight build — the
|
||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||
# into this: they are immutable and unique, so no tag build can ever be
|
||||
# superseded by another run on the same ref.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-release:
|
||||
name: Build signed APK (tag releases only)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
# all. Without it the only way to get a build onto a phone was to cut a
|
||||
# release, which quietly turns `main` into the staging area.
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
@@ -75,14 +135,18 @@ jobs:
|
||||
outputs:
|
||||
version_name: ${{ steps.ver.outputs.name }}
|
||||
version_code: ${{ steps.ver.outputs.code }}
|
||||
channel: ${{ steps.ver.outputs.channel }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0 retrieves full history; default shallow clone
|
||||
# would return 1 for `git rev-list --count HEAD`, breaking the
|
||||
# iteration suffix.
|
||||
# Full history. The version name now reads only the tip commit's
|
||||
# timestamp, so a shallow clone would technically serve — but this
|
||||
# job derives a value that ships to devices, and a shallow checkout
|
||||
# changes what git-derived values resolve to WITHOUT failing. The
|
||||
# whole failure class here is a green build carrying a wrong
|
||||
# version, so the cheap guarantee is worth keeping.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute release version
|
||||
@@ -91,12 +155,23 @@ jobs:
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
|
||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||
# The derivation lives in ci/version.sh, not here, so it can be
|
||||
# executed by a test on every push. Anything inline in this file is
|
||||
# unverifiable until a release is already running.
|
||||
out="$(ci/version.sh HEAD)"
|
||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The channel is a property of the LANE, not of the commit, which is
|
||||
# why it is derived here rather than in version.sh. Same commit built
|
||||
# on dev and on main reports the same NAME and differs only here —
|
||||
# that is the whole point of separating the two values.
|
||||
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
|
||||
channel=dev
|
||||
else
|
||||
channel=stable
|
||||
fi
|
||||
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
|
||||
|
||||
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||
# Release" below resolves the release by tag and fails if it is absent —
|
||||
@@ -108,6 +183,7 @@ jobs:
|
||||
# the release together, so this passes). A bare `git push origin vX` is the
|
||||
# case this catches.
|
||||
- name: Release must exist for this tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
@@ -156,13 +232,12 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||
# Scribe spike #3843), so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -171,9 +246,15 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
# Tag releases only. A dev build has no Release to hang assets on and
|
||||
# does not need one — the :dev image bundles the APK, and the server
|
||||
# serves it from /api/client/apk like any other.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
||||
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
@@ -181,6 +262,20 @@ jobs:
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
ls -lh "${APK_PATH}"
|
||||
|
||||
# Publish the version sidecar as a release asset next to the APK.
|
||||
#
|
||||
# This is what lets a later :latest build stop RECONSTRUCTING the
|
||||
# bundled APK's version and simply read what was recorded. The
|
||||
# ordering key in particular cannot be re-derived after the fact —
|
||||
# it is build-time minutes, so once this job ends the value exists
|
||||
# nowhere else. Reconstruction could only ever recover the name,
|
||||
# and only by duplicating a formula that then has to be kept in
|
||||
# step across two files.
|
||||
SIDECAR_PATH="/tmp/minstrel.apk.version"
|
||||
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
|
||||
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
|
||||
cat "${SIDECAR_PATH}"
|
||||
|
||||
RELEASE_JSON="$(curl -fsSL \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
|
||||
@@ -202,6 +297,20 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Same treatment for the sidecar. Named `.apk.version` so the
|
||||
# downloader's `\.apk$` match cannot pick it up by mistake.
|
||||
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@${SIDECAR_PATH}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
|
||||
echo "sidecar_upload_http=${SIDECAR_HTTP}"
|
||||
cat /tmp/upload-sidecar.out || true
|
||||
echo
|
||||
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
|
||||
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# `needs:` waits for android-release. For tag pushes android-release
|
||||
@@ -222,11 +331,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# value, silently, with every lane green.
|
||||
#
|
||||
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||
# now runs ci/version.sh itself, because with :<version> image tags
|
||||
# gone the server's self-reported version is the only thing that says
|
||||
# which build an image is.
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -244,21 +358,68 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||
# rolling stable channel — every main push moves :latest.
|
||||
# Pinned consumers can target :vYYYY.MM.DD; everyone else
|
||||
# gets the newest main.
|
||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
set -euo pipefail
|
||||
|
||||
# THE VERSION, and it is derived the same way on every ref — the
|
||||
# branch decides the CHANNEL, never the version (family rule 149).
|
||||
#
|
||||
# This used to be three different things: the literal string "main"
|
||||
# on main, "dev" on dev, and the tag name on a tag. None of them
|
||||
# ordered, and the first two were the same string forever — two dev
|
||||
# images eight weeks apart were indistinguishable in the UI. That
|
||||
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
||||
# build; with version image tags gone, this IS how an operator tells
|
||||
# which build a container is running.
|
||||
#
|
||||
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
||||
# so the empty check below is actually reachable. A grep here would
|
||||
# kill the step at the assignment under the runner's pipefail — the
|
||||
# exact bug that took down the first main build after the version
|
||||
# rework.
|
||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "::error::could not derive a build version from ci/version.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
# A release refreshes the CHANNEL and mints nothing else.
|
||||
#
|
||||
# The tag build exists to produce the signed APK and attach it to
|
||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||
# build minutes earlier, differing only in which APK is baked in.
|
||||
# Rule 145 is explicit about that case: when the same source is
|
||||
# rebuilt with different contents, publish the moving channel tag
|
||||
# and never a commit-addressable one.
|
||||
#
|
||||
# :latest must move here rather than waiting for the next main
|
||||
# push, or the channel would carry the PREVIOUS release's APK
|
||||
# indefinitely — a channel that cannot refresh itself (rule 146).
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
||||
# The rolling test channel, and :dev ALONE — deliberately no
|
||||
# per-commit tag. A rolling channel is rolling by definition, so a
|
||||
# commit-addressable image here would be a rollback target nobody
|
||||
# has ever pulled, accumulating in the registry forever. Recovery
|
||||
# on dev is to fix forward.
|
||||
CHANNEL=dev
|
||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
else
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||
# the family's other repos, so a rollback target is addressable
|
||||
# straight from the commit anyone is reading.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -267,54 +428,57 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||
# the same one proven against this instance by the upload side.
|
||||
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||
# takes the "Bundle latest release APK" path below, because it is the
|
||||
# one ref that moves a channel without building an APK of its own.
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle; snippet #2271). v8 runs on node24, which every
|
||||
# CI-runner image carries — the runner uses the image's own node.
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
shell: bash
|
||||
env:
|
||||
# Pulled from android-release.outputs.version_name so the
|
||||
# sidecar string the server hands clients matches the
|
||||
# versionName baked into the APK they're comparing against.
|
||||
# All three pulled from android-release's outputs so the sidecar the
|
||||
# server hands clients matches exactly what is baked into the APK
|
||||
# they are comparing against.
|
||||
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
||||
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
# The artifact lands as `app-release.apk` (the original Gradle
|
||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||
mv client/app-release.apk client/minstrel.apk
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
|
||||
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
|
||||
> client/minstrel.apk.version
|
||||
cat client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# the sidecar published beside it, so what the server reports is what
|
||||
# that build actually recorded rather than something re-derived here.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
# version — if no release or APK asset can be resolved. That
|
||||
# degradation only actually works because the greps below carry
|
||||
# `|| true`; under the runner's default pipefail a non-matching grep
|
||||
# kills the step instead of falling through to the empty-case branch.
|
||||
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -326,19 +490,40 @@ jobs:
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
# `|| true` on every one of these, and it is load-bearing rather
|
||||
# than defensive habit. The runner already invokes this shell as
|
||||
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
||||
# exits non-zero even though `head` succeeded — and the step dies at
|
||||
# the assignment, before ever reaching the `if` written to handle the
|
||||
# empty case. Every "degrades gracefully" branch below is unreachable
|
||||
# without this.
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
|
||||
# Take the version the release RECORDED rather than recomputing it.
|
||||
# This used to re-derive the name from the tagged commit, which meant
|
||||
# the formula lived in two files that had to be kept in step, and it
|
||||
# could only ever recover the name — the ordering key is build-time
|
||||
# minutes and does not exist anywhere after that build ends.
|
||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
||||
if [ -n "${SIDECAR_URL}" ]; then
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
||||
cat client/minstrel.apk.version
|
||||
else
|
||||
# Releases published before sidecars were attached. Their name is
|
||||
# still recoverable from the tag, but their ordering key genuinely
|
||||
# is not — so it is reported ABSENT rather than guessed. A wrong
|
||||
# key is an install the platform refuses; an absent one just tells
|
||||
# the client to fall back to comparing names, which is exactly
|
||||
# what those builds already do.
|
||||
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
||||
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
||||
fi
|
||||
echo "::notice::bundled release APK from ${TAG}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
@@ -346,6 +531,7 @@ jobs:
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
@@ -356,8 +542,8 @@ jobs:
|
||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its immutable
|
||||
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
#
|
||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||
# anything in this file). What it does is turn an incomplete release into an
|
||||
@@ -408,18 +594,30 @@ jobs:
|
||||
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||
# it runs even when image-release failed, so without this the guard would
|
||||
# cheerfully verify an incomplete release.
|
||||
- name: Immutable image tag must exist
|
||||
#
|
||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||
# that assertion would now fail every release for a tag nothing mints.
|
||||
# The rollback target it was really protecting is the :<sha> image, which
|
||||
# main's own build published for this same commit before the tag was cut.
|
||||
#
|
||||
# Checking it here earns its keep twice over: it still catches an image
|
||||
# push that silently did not happen, and it additionally proves the
|
||||
# ORDERING — a tag cut on a commit whose main build never completed has
|
||||
# no rollback target, and that is worth failing on rather than
|
||||
# discovering during an incident.
|
||||
- name: Rollback image must exist for the tagged commit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
||||
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
@@ -32,6 +32,12 @@ on:
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.gitea/workflows/test-go.yml'
|
||||
# The release lane's own trigger is `main` + tags, so nothing it
|
||||
# contains is exercised until a release is already running. These two
|
||||
# entries are what let internal/server/release_version_test.go guard
|
||||
# the version derivation on ordinary dev pushes instead.
|
||||
- 'ci/**'
|
||||
- '.gitea/workflows/release.yml'
|
||||
|
||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
|
||||
# binary committed by accident, last refreshed by a commit about web test
|
||||
# mocks, and re-dirtied by every local build since.
|
||||
bin/
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
|
||||
@@ -15,12 +15,21 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
# Version stamping. release.yml passes the DERIVED version name
|
||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||
# to "dev"/"local". Both are surfaced at /healthz.
|
||||
#
|
||||
# These are two values on purpose (family rule 149): the same commit built on
|
||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||
# the channel into the version string is what the rule forbids — the version
|
||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||
# eight weeks apart were indistinguishable.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-ldflags="-s -w \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -112,11 +112,21 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
||||
|
||||
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
|
||||
|
||||
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
|
||||
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||
|
||||
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -150,7 +160,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
@@ -21,13 +21,24 @@ android {
|
||||
applicationId = "com.fabledsword.minstrel"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
// versionName / versionCode are released-build values injected by
|
||||
// CI from the git tag + commit count. Local / debug builds fall
|
||||
// back to "dev" so the About card reads honestly. Releases ship
|
||||
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
|
||||
// versionCode=<commits>, which is monotonic forever and lets the
|
||||
// shared isVersionNewer comparator distinguish two same-day
|
||||
// re-cuts (the iteration suffix differs).
|
||||
// versionName / versionCode are released-build values injected by CI.
|
||||
// Local / debug builds fall back to "dev" so the About card reads
|
||||
// honestly.
|
||||
//
|
||||
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
|
||||
// every lane building this source reports the same string and the
|
||||
// channel is the only thing that differs between them.
|
||||
//
|
||||
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
|
||||
// value the platform decides installs by, so it must be monotonic by
|
||||
// construction.
|
||||
//
|
||||
// This comment used to say versionCode was a commit count and that it
|
||||
// was "monotonic forever". It was neither — a commit count runs ahead
|
||||
// on `dev`, so a dev build outranked the `main` release meant to
|
||||
// replace it and Android refused the install as a downgrade. Worth
|
||||
// knowing the claim was here, stated as a reassurance, while the bug
|
||||
// it denied was live.
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
@@ -150,7 +161,6 @@ dependencies {
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.text.google.fonts)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Wire shape returned by `GET /api/client/version`. Mirrors
|
||||
* the Flutter client's `UpdateInfo`.
|
||||
* The server-bundled APK, as reported by `GET /api/client/version`.
|
||||
*
|
||||
* `version` is the server-bundled APK version (may have a leading
|
||||
* "v" from the git tag); `apkUrl` is server-relative (e.g.
|
||||
* `/api/client/apk`); `sizeBytes` is the download size.
|
||||
* Three values that are deliberately kept apart:
|
||||
*
|
||||
* - [version] is a LABEL for people — "YYYY.MM.DD.HHMM", derived from the
|
||||
* build's commit, so two channels carrying the same code read the same.
|
||||
* Display this; never decide on it when [code] is present.
|
||||
* - [code] is the ORDERING KEY, and is the same value Android itself
|
||||
* installs by. It answers "may this be installed over that?", which the
|
||||
* name cannot. Null when the server predates the field.
|
||||
* - [channel] is a SIBLING FIELD, never a suffix inside the name. Reported
|
||||
* verbatim rather than validated, so an unexpected value is shown rather
|
||||
* than dropped.
|
||||
*
|
||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
val code: Long?,
|
||||
val channel: String?,
|
||||
val apkUrl: String,
|
||||
val sizeBytes: Long,
|
||||
)
|
||||
|
||||
@@ -4,12 +4,26 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `GET /api/client/version`. Defaults match Flutter:
|
||||
* apk_url falls back to `/api/client/apk` if the server omits it.
|
||||
* Wire shape for `GET /api/client/version`.
|
||||
*
|
||||
* `apkUrl` falls back to `/api/client/apk` if the server omits it.
|
||||
*
|
||||
* [code] MUST stay nullable, and this is not a style preference. The app's
|
||||
* Json is configured with `coerceInputValues = true`, which replaces a JSON
|
||||
* null with the declared default for a NON-nullable property — so writing
|
||||
* `val code: Long = 0` would turn "this server reports no ordering key" into
|
||||
* "this build's ordering key is 0", silently, with no error anywhere. A
|
||||
* nullable type is what keeps absent distinguishable from zero, and the
|
||||
* distinction is the whole reason the field exists.
|
||||
*
|
||||
* A server predating the ordering key sends neither [code] nor [channel];
|
||||
* both arrive null and the caller falls back to comparing names.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateInfoWire(
|
||||
val version: String = "",
|
||||
val code: Long? = null,
|
||||
val channel: String? = null,
|
||||
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||
)
|
||||
|
||||
@@ -109,8 +109,11 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
|
||||
* NowPlayingScreen via [onExpandClick].
|
||||
*
|
||||
* Layout (Column):
|
||||
* - Slim seek slider at the top (4dp track)
|
||||
* - Row: cover | title/artist column | like | prev | play/pause | next
|
||||
* - Slim seek slider pinned at the top (4dp track)
|
||||
* - Row: cover | title/artist column | like | prev | play/pause | next.
|
||||
* Weighted so it fills the rest of the fixed-height bar and centres its
|
||||
* own content; otherwise the row keeps its intrinsic 48dp and the
|
||||
* leftover height collects at the bottom as dead surface.
|
||||
*
|
||||
* No kebab on the mini bar (operator 2026-06-01): the full kebab
|
||||
* surface lives on NowPlayingScreen, and dropping it from the mini
|
||||
@@ -164,6 +167,12 @@ fun MiniPlayer(
|
||||
durationMs = state.durationMs,
|
||||
)
|
||||
MiniRow(
|
||||
// Take whatever the progress fill leaves. Without this the
|
||||
// Column stacks 4dp + the row's intrinsic 48dp from the top
|
||||
// and the remaining 28dp of an 80dp bar sits empty
|
||||
// underneath — the content looked top-aligned rather than
|
||||
// centred, with a dead strip above the gesture bar.
|
||||
modifier = Modifier.weight(1f),
|
||||
track = track,
|
||||
isPlaying = state.isPlaying,
|
||||
isUpnpLoading = state.isUpnpLoading,
|
||||
@@ -205,6 +214,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
|
||||
@Composable
|
||||
@Suppress("LongParameterList")
|
||||
private fun MiniRow(
|
||||
modifier: Modifier,
|
||||
track: TrackRef,
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
@@ -216,7 +226,7 @@ private fun MiniRow(
|
||||
onToggleLike: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -9,7 +9,7 @@ import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import com.fabledsword.minstrel.update.data.isUpdateAvailable
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -37,6 +37,10 @@ sealed interface UpdateCheckResult {
|
||||
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
// The value the platform installs by, and therefore the one the update
|
||||
// check must decide on. Held in state rather than read inline so a test
|
||||
// can drive the comparison without a BuildConfig.
|
||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||
val isChecking: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
@@ -45,8 +49,9 @@ data class AboutUiState(
|
||||
|
||||
/**
|
||||
* Backs the About card's update controls. "Check for updates" calls
|
||||
* [UpdateRepository.getLatest], compares versus the build's
|
||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||
* [UpdateRepository.getLatest], compares versus this build via
|
||||
* [isUpdateAvailable] — on the ordering key where the server reports one,
|
||||
* on the name otherwise — and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
@@ -66,9 +71,17 @@ class AboutCardViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||
val installed = internal.value.installedVersion
|
||||
val installedCode = internal.value.installedCode
|
||||
val result = runCatching { repository.getLatest() }
|
||||
.map { latest ->
|
||||
if (isVersionNewer(latest.version, installed)) {
|
||||
if (
|
||||
isUpdateAvailable(
|
||||
serverCode = latest.code,
|
||||
serverName = latest.version,
|
||||
installedCode = installedCode,
|
||||
installedName = installed,
|
||||
)
|
||||
) {
|
||||
UpdateCheckResult.UpdateAvailable(latest)
|
||||
} else {
|
||||
UpdateCheckResult.Latest
|
||||
|
||||
@@ -2,72 +2,45 @@ package com.fabledsword.minstrel.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.fabledsword.minstrel.R
|
||||
|
||||
/**
|
||||
* Google Fonts provider — fetches font files via Play Services Fonts at
|
||||
* runtime, caches them across launches. Matches the Flutter client's
|
||||
* `google_fonts` package behaviour (no bundled .ttf files in either tree).
|
||||
* Bundled typefaces, vendored into res/font by tools/vendor-fonts.py.
|
||||
*
|
||||
* These were fetched at runtime through the Play Services font provider until
|
||||
* 2026-09-09. That is a network dependency for rendering, and a deployed
|
||||
* instance is not guaranteed one — the provider is also absent entirely on
|
||||
* devices without Play Services, where the app silently fell back to the
|
||||
* platform default and stopped looking like Minstrel. Bundling costs ~0.86 MB
|
||||
* of APK and removes both failure modes.
|
||||
*
|
||||
* Per FabledSword design system:
|
||||
* - Fraunces — display + headline (mythic serif)
|
||||
* - Inter — body + label (clean sans for UI text)
|
||||
* - JetBrains Mono — technical / monospace
|
||||
* Weights are restricted to 400 (regular) and 500 (medium) only.
|
||||
*
|
||||
* Each res/font entry is a single static instance, not a variable font: the
|
||||
* weight declared beside it here must match the file's own OS/2
|
||||
* usWeightClass, which the vendoring script asserts on download.
|
||||
*/
|
||||
private val GoogleFontProvider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
private val FrauncesFont = GoogleFont("Fraunces")
|
||||
private val InterFont = GoogleFont("Inter")
|
||||
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
|
||||
|
||||
private val Fraunces = FontFamily(
|
||||
Font(
|
||||
googleFont = FrauncesFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(
|
||||
googleFont = FrauncesFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W500,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal),
|
||||
Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal),
|
||||
)
|
||||
|
||||
private val Inter = FontFamily(
|
||||
Font(
|
||||
googleFont = InterFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(
|
||||
googleFont = InterFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W500,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal),
|
||||
Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal),
|
||||
)
|
||||
|
||||
private val JetBrainsMono = FontFamily(
|
||||
Font(
|
||||
googleFont = JetBrainsMonoFont,
|
||||
fontProvider = GoogleFontProvider,
|
||||
weight = FontWeight.W400,
|
||||
style = FontStyle.Normal,
|
||||
),
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,8 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||
/**
|
||||
* Drives the shell's soft "update available" banner. Polls
|
||||
* `/api/client/version` at launch + every 24h and, when the bundled
|
||||
* APK is strictly newer than this build, exposes its [UpdateInfo] so
|
||||
* APK outranks this build — by ordering key where the server reports one,
|
||||
* by name otherwise — exposes its [UpdateInfo] so
|
||||
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
|
||||
* install. Mirrors Flutter's `ClientUpdateController`.
|
||||
*
|
||||
@@ -58,6 +59,13 @@ class UpdateBannerController @Inject constructor(
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
|
||||
latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) }
|
||||
latest.value = info.takeIf {
|
||||
isUpdateAvailable(
|
||||
serverCode = it.code,
|
||||
serverName = it.version,
|
||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||
installedName = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,39 @@ class UpdateRepository @Inject constructor(retrofit: Retrofit) {
|
||||
|
||||
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||
version = version,
|
||||
code = code,
|
||||
channel = channel,
|
||||
apkUrl = apkUrl,
|
||||
sizeBytes = sizeBytes,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when [server] should be offered over the installed build.
|
||||
*
|
||||
* **Decide on the ordering key whenever the server sends one.** That is the
|
||||
* same value Android's package installer compares, so an offer made this way
|
||||
* implies an install the platform will actually accept. The app used to
|
||||
* compare NAMES while the platform installed by `versionCode`, with nothing
|
||||
* keeping the two orderings consistent — so it could offer a build Android
|
||||
* then refused as a downgrade, or stay quiet about one it would have taken.
|
||||
*
|
||||
* Name comparison survives only as the fallback for a server that predates
|
||||
* the field. A null code means "this server cannot tell me" — never "zero" —
|
||||
* because treating absent as zero would rank every such server as infinitely
|
||||
* old and offer its build to everyone, forever.
|
||||
*/
|
||||
fun isUpdateAvailable(
|
||||
serverCode: Long?,
|
||||
serverName: String,
|
||||
installedCode: Long,
|
||||
installedName: String,
|
||||
): Boolean =
|
||||
if (serverCode != null) {
|
||||
serverCode > installedCode
|
||||
} else {
|
||||
isVersionNewer(serverName, installedName)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [server] is strictly newer than [installed]. Mirrors
|
||||
* Flutter's `isVersionNewer` — splits both strings on `.`, parses
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Google Fonts provider certificate hashes for downloadable fonts via
|
||||
androidx.compose.ui.text.googlefonts.GoogleFont.Provider. Standard
|
||||
values published by Google; copied verbatim from the AndroidX docs. -->
|
||||
<resources>
|
||||
<array name="com_google_android_gms_fonts_certs">
|
||||
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||
</array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||
<item>MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=</item>
|
||||
</string-array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg=</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.fabledsword.minstrel.theme
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Guards that the typefaces ship inside the APK instead of being fetched at
|
||||
* runtime.
|
||||
*
|
||||
* Until 2026-09-09 these were resolved through the Play Services font
|
||||
* provider. That needs a network the deployed app is not guaranteed, and a
|
||||
* provider that devices without Play Services do not have at all. Both
|
||||
* failures are silent — text just renders in the platform default, which
|
||||
* reads as a styling regression rather than a missing dependency.
|
||||
*
|
||||
* Expectations are read out of Typography.kt itself rather than hardcoded, so
|
||||
* this cannot drift away from what the app actually declares: adding a face
|
||||
* without vendoring its file fails here, and so does changing a declared
|
||||
* weight without refetching the matching static instance.
|
||||
*/
|
||||
class BundledFontsTest {
|
||||
@Test
|
||||
fun `typography builds its families from bundled resources`() {
|
||||
val source = typographySource()
|
||||
assertTrue(
|
||||
source.contains("R.font."),
|
||||
"Typography.kt should build its families from res/font resources",
|
||||
)
|
||||
FORBIDDEN.forEach { symbol ->
|
||||
assertTrue(
|
||||
!source.contains(symbol),
|
||||
"Typography.kt must not reference $symbol — fonts are bundled, not fetched",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every declared face is vendored as TrueType at its declared weight`() {
|
||||
val declared = FACE_PATTERN.findAll(typographySource()).toList()
|
||||
assertTrue(
|
||||
declared.isNotEmpty(),
|
||||
"no Font(R.font.…, FontWeight.W…) declarations found — the guard would pass vacuously",
|
||||
)
|
||||
|
||||
declared.forEach { match ->
|
||||
val (name, weight) = match.destructured
|
||||
val file = File(appDir(), "src/main/res/font/$name.ttf")
|
||||
assertTrue(file.isFile, "res/font/$name.ttf is missing — run tools/vendor-fonts.py")
|
||||
|
||||
val bytes = file.readBytes()
|
||||
assertTrue(
|
||||
bytes.copyOfRange(0, TTF_MAGIC.size).contentEquals(TTF_MAGIC),
|
||||
"$name.ttf is not TrueType — res/font cannot load a woff2 or an eot",
|
||||
)
|
||||
// The decisive check. Google's css2 endpoint silently collapses a
|
||||
// multi-weight request to 400 for legacy clients, so Medium can
|
||||
// come back as Regular: a valid TrueType file that renders at the
|
||||
// wrong weight everywhere. usWeightClass is the only field that
|
||||
// tells the two apart.
|
||||
assertEquals(
|
||||
weight.toInt(),
|
||||
weightClass(bytes),
|
||||
"$name.ttf carries a different OS/2 usWeightClass than the FontWeight declared beside it",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Typography.kt with comments removed, so prose naming the forbidden
|
||||
* symbols cannot satisfy — or trip — the absence check above. */
|
||||
private fun typographySource(): String =
|
||||
File(appDir(), TYPOGRAPHY)
|
||||
.readText()
|
||||
.replace(BLOCK_COMMENT, "")
|
||||
.replace(LINE_COMMENT, "")
|
||||
|
||||
/** Gradle's working directory for tests is the module dir, but don't rely
|
||||
* on it: walk up until the module is found, and say so if it isn't. */
|
||||
private fun appDir(): File {
|
||||
var dir: File? = File("").absoluteFile
|
||||
while (dir != null) {
|
||||
if (File(dir, TYPOGRAPHY).isFile) return dir
|
||||
if (File(dir, "app/$TYPOGRAPHY").isFile) return File(dir, "app")
|
||||
dir = dir.parentFile
|
||||
}
|
||||
error("could not locate the app module from ${File("").absolutePath}")
|
||||
}
|
||||
|
||||
private fun weightClass(bytes: ByteArray): Int {
|
||||
val tables = readU16(bytes, NUM_TABLES)
|
||||
for (i in 0 until tables) {
|
||||
val record = TABLE_DIRECTORY + i * TABLE_RECORD
|
||||
if (String(bytes, record, TAG_LENGTH, Charsets.US_ASCII) == "OS/2") {
|
||||
return readU16(bytes, readU32(bytes, record + OFFSET_FIELD) + WEIGHT_FIELD)
|
||||
}
|
||||
}
|
||||
error("no OS/2 table in the font")
|
||||
}
|
||||
|
||||
private fun readU16(bytes: ByteArray, at: Int): Int =
|
||||
((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)
|
||||
|
||||
private fun readU32(bytes: ByteArray, at: Int): Int =
|
||||
(readU16(bytes, at) shl Short.SIZE_BITS) or readU16(bytes, at + 2)
|
||||
|
||||
private companion object {
|
||||
const val TYPOGRAPHY = "src/main/java/com/fabledsword/minstrel/theme/Typography.kt"
|
||||
|
||||
val FORBIDDEN = listOf("GoogleFont", "googlefonts")
|
||||
val FACE_PATTERN = Regex("""R\.font\.(\w+)\s*,\s*FontWeight\.W(\d+)""")
|
||||
val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""")
|
||||
val LINE_COMMENT = Regex("""//.*""")
|
||||
val TTF_MAGIC = byteArrayOf(0x00, 0x01, 0x00, 0x00)
|
||||
|
||||
// Offsets into the TrueType table directory, per the OpenType spec.
|
||||
const val NUM_TABLES = 4
|
||||
const val TABLE_DIRECTORY = 12
|
||||
const val TABLE_RECORD = 16
|
||||
const val TAG_LENGTH = 4
|
||||
const val OFFSET_FIELD = 8
|
||||
const val WEIGHT_FIELD = 4
|
||||
const val BYTE_MASK = 0xFF
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The update channel had no tests at all before this. That is worth saying
|
||||
* out loud, because the thing it decides — whether anyone is ever offered an
|
||||
* update — fails silently in both directions: an update nobody is offered
|
||||
* looks exactly like being up to date, and nobody files a bug about a prompt
|
||||
* they never saw.
|
||||
*/
|
||||
class UpdateVersioningTest {
|
||||
@Test
|
||||
fun `decides on the ordering key when the server reports one`() {
|
||||
assertTrue(
|
||||
isUpdateAvailable(
|
||||
serverCode = 3523847, serverName = "2026.09.10.1432",
|
||||
installedCode = 3519456, installedName = "2026.09.09.1828",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = 3519456, serverName = "2026.09.09.1828",
|
||||
installedCode = 3523847, installedName = "2026.09.10.1432",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an equal ordering key is not an update`() {
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = 3523847, serverName = "2026.09.10.1432",
|
||||
installedCode = 3523847, installedName = "2026.09.10.1432",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The property the whole rework exists for: the offer must agree with what
|
||||
* the platform will actually install. Where the two disagree, the ordering
|
||||
* key wins, because that is the value Android compares.
|
||||
*/
|
||||
@Test
|
||||
fun `the ordering key wins even when the name disagrees`() {
|
||||
// Name looks older, key is newer — e.g. an older commit rebuilt later.
|
||||
assertTrue(
|
||||
isUpdateAvailable(
|
||||
serverCode = 9_000_000, serverName = "2020.01.01.0000",
|
||||
installedCode = 1, installedName = "2099.12.31.2359",
|
||||
),
|
||||
)
|
||||
// Name looks newer, key is not. Offering this would be offering an
|
||||
// install the platform then refuses as a downgrade.
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = 1, serverName = "2099.12.31.2359",
|
||||
installedCode = 9_000_000, installedName = "2020.01.01.0000",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the name when the server reports no ordering key`() {
|
||||
assertTrue(
|
||||
isUpdateAvailable(
|
||||
serverCode = null, serverName = "2026.09.10.1432",
|
||||
installedCode = 3519456, installedName = "2026.09.09.1828",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = null, serverName = "2026.09.09.1828",
|
||||
installedCode = 3519456, installedName = "2026.09.10.1432",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A null code must never be read as zero. Zero would rank every
|
||||
* older server as infinitely behind and offer its build to everyone,
|
||||
* forever — so this asserts the fallback runs instead of a comparison
|
||||
* against 0 succeeding by accident.
|
||||
*/
|
||||
@Test
|
||||
fun `a null ordering key is absent, not zero`() {
|
||||
// installedCode is 0 here: if null coerced to 0, "0 > 0" would be
|
||||
// false and this would wrongly report no update despite a newer name.
|
||||
assertTrue(
|
||||
isUpdateAvailable(
|
||||
serverCode = null, serverName = "2026.09.10.1432",
|
||||
installedCode = 0, installedName = "2026.09.09.1828",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded migration constraint, pinned so it cannot be forgotten:
|
||||
* the old scheme's fourth segment was a commit count (~1895), the new
|
||||
* one is HHMM. Across a day boundary the date decides and all is well.
|
||||
*/
|
||||
@Test
|
||||
fun `new-scheme name outranks an old-scheme name on a later day`() {
|
||||
assertTrue(isVersionNewer("2026.09.10.1432", "2026.09.09.1895"))
|
||||
}
|
||||
|
||||
/**
|
||||
* ...but on the SAME day the comparison comes down to HHMM against a
|
||||
* commit count, and any build before ~19:00 UTC reads as older. This is
|
||||
* why the first new-scheme release had to be cut on a later calendar day.
|
||||
* Asserting the trap so nobody "fixes" it by accident.
|
||||
*/
|
||||
@Test
|
||||
fun `same-day new-scheme name can read older than an old-scheme name`() {
|
||||
assertFalse(isVersionNewer("2026.09.09.1828", "2026.09.09.1895"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `name comparison degrades per segment rather than discarding`() {
|
||||
// The string is still compared rather than rejected outright: an
|
||||
// earlier segment decides and the unparseable tail never matters.
|
||||
assertTrue(isVersionNewer("2026.09.10.1432-dev", "2026.09.09.1828"))
|
||||
|
||||
// A shorter name pads with zeros instead of being refused.
|
||||
assertTrue(isVersionNewer("2026.09.10", "2026.09.09.9999"))
|
||||
assertFalse(isVersionNewer("2026.09.10", "2026.09.10.0"))
|
||||
}
|
||||
|
||||
/**
|
||||
* What "costs that segment's precision" actually means, and it is worth
|
||||
* pinning because it is a real edge rather than a nicety: when the
|
||||
* unparseable segment is the DECIDING one, it reads as 0 and loses. So a
|
||||
* `-dev` suffixed build compares as older than an unsuffixed one from the
|
||||
* same minute.
|
||||
*
|
||||
* That is the correct behaviour for a degrading parser — it is bounded
|
||||
* loss rather than a discarded string — but it is exactly why the channel
|
||||
* belongs in its own field and never in the name.
|
||||
*/
|
||||
@Test
|
||||
fun `an unparseable deciding segment reads as zero and loses`() {
|
||||
assertFalse(isVersionNewer("2026.09.10.1432-dev", "2026.09.10.1000"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sides unparseable (branch-name builds) falls back to string
|
||||
* inequality, so a dev build still surfaces rather than comparing equal
|
||||
* and going silent.
|
||||
*/
|
||||
@Test
|
||||
fun `two unparseable names fall back to string inequality`() {
|
||||
assertTrue(isVersionNewer("main", "dev"))
|
||||
assertFalse(isVersionNewer("dev", "dev"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading v is ignored on either side`() {
|
||||
assertTrue(isVersionNewer("v2026.09.10.1432", "2026.09.09.1828"))
|
||||
assertFalse(isVersionNewer("v2026.09.10.1432", "v2026.09.10.1432"))
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
|
||||
@@ -62,57 +62,30 @@ None.
|
||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
```
|
||||
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||
and Gitea then serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
||||
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
|
||||
on 2026-09-10 for upload v4–v7 and download v4–v8 (Scribe spike #3843). Until
|
||||
then this repo pinned SHA mirrors of the Forgejo project's forks, because
|
||||
upstream threw on the hostname before it opened a connection (Scribe 2255).
|
||||
|
||||
Both are pull mirrors of the Forgejo project's forks
|
||||
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||
otherwise silently change what runs.
|
||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||
stored but invisible to every retrieval path. That is how 72 unreachable
|
||||
artifacts accumulated on this repo (Scribe 2270).
|
||||
|
||||
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||
numbers.** The two actions release on unrelated cadences, so equal version
|
||||
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||
across it yields an empty listing rather than an error, exactly the silent
|
||||
failure this entry exists to prevent.
|
||||
|
||||
| tag | `@actions/artifact` | runtime |
|
||||
|---|---|---|
|
||||
| upload v4 | ^2.1.1 | node20 |
|
||||
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||
| download v4 | ^2.1.1 | node20 |
|
||||
| download v5 | ^2.3.2 | node20 |
|
||||
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||
| download v7 | ^5.0.0 | **node24** |
|
||||
|
||||
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||
|
||||
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||
here. Everything currently pinned is node20.
|
||||
**Pairing no longer needs managing.** This entry used to pin upload v5 against
|
||||
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
|
||||
across `release.yml`'s producer/consumer pair would list empty. Tested, and not
|
||||
true on this instance: every download major v4–v8 read the artifacts of every
|
||||
upload major v4–v7, by name and by pattern (CI-runner run 6312). The only real
|
||||
protocol break is v3 → v4. node24 is no longer a concern either — every
|
||||
CI-runner image carries Node 24 and the runner runs actions with the image's
|
||||
`node`.
|
||||
|
||||
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||
an upload that matches nothing fails its own job instead of failing the
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Derives the three values a build is stamped with, and the tag that names it.
|
||||
#
|
||||
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
|
||||
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||
# tag=v<name> what a release of this commit must be called
|
||||
#
|
||||
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
|
||||
#
|
||||
# This exists as a script rather than inline workflow YAML for one reason:
|
||||
# release.yml only runs on `main` and on tags, so anything living inside it is
|
||||
# unverifiable until a release is already happening — which is the worst
|
||||
# possible moment to discover the version is wrong, because the failure mode
|
||||
# is silent (an update nobody is offered looks exactly like being current).
|
||||
# As a script it can be executed by a test on every push instead.
|
||||
#
|
||||
# The two clocks are deliberate and are NOT interchangeable:
|
||||
#
|
||||
# The NAME answers "is this the same code?" — so it must read identically on
|
||||
# every lane that builds this commit. Commit time does that; build time
|
||||
# prints two different strings for one thing.
|
||||
#
|
||||
# The CODE answers "may this be installed over that?" — so it must be
|
||||
# monotonic BY CONSTRUCTION. Build time is; commit time is not (rebuild an
|
||||
# older commit and it goes down, which on a phone is a refused install), and
|
||||
# a commit COUNT is worse still, because it runs ahead on `dev` and inverts
|
||||
# against `main`.
|
||||
set -euo pipefail
|
||||
|
||||
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
||||
readonly REF="${1:-HEAD}"
|
||||
|
||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||
# set these — the defaults are the real derivation.
|
||||
# The paths that do NOT ship, in either artifact. Everything else counts.
|
||||
#
|
||||
# A DENYLIST, and the direction is the whole point. As an allowlist, the list
|
||||
# has to be updated by whoever adds a directory and nothing fails if they
|
||||
# don't — so the failure mode is a changed artifact keeping its old version,
|
||||
# silently, on a green run. That is a build lying about what it is. Inverted,
|
||||
# new content counts by default and the only way to wrongly EXCLUDE something
|
||||
# is to name it here deliberately.
|
||||
#
|
||||
# The two error directions are not symmetric, which is why this is not taste:
|
||||
# wrongly excluded → changed artifact, unchanged version. A silent lie.
|
||||
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
||||
# a string nobody sorts.
|
||||
#
|
||||
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
|
||||
# is shorter than it looks like it should be. The server image ships cmd/,
|
||||
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
|
||||
# android/. Neither ships the other's sources — but excluding android/ here
|
||||
# would stop an Android-only commit from moving the APK's OWN version, which
|
||||
# is the dangerous direction. So this is the union: exclude only what ships in
|
||||
# NEITHER, and accept that an Android commit also nudges the server's reported
|
||||
# version. Over-inclusion across the two, which is the harmless direction.
|
||||
#
|
||||
# The family's other repos (roundtable / roundtable-android) each keep a
|
||||
# tighter list because they are separate repos with one artifact apiece. Do
|
||||
# not copy theirs onto this one.
|
||||
readonly SHIPPED=(
|
||||
.
|
||||
':!.gitea' # CI workflows — including this script's own caller
|
||||
':!ci' # CI scripts — including this script
|
||||
':!docs'
|
||||
':!tools' # asset/font generators; their OUTPUT ships, they do not
|
||||
':!deploy' # test-database bootstrap SQL
|
||||
':!bin' # local `make build` output
|
||||
':!*.md'
|
||||
':!Makefile'
|
||||
':!docker-compose.yml'
|
||||
':!.env.example'
|
||||
':!.gitignore'
|
||||
':!.dockerignore'
|
||||
':!renovate.json'
|
||||
':!.golangci.yml'
|
||||
|
||||
# TESTS DO NOT SHIP, so they must not re-version an artifact.
|
||||
#
|
||||
# Named as globs rather than a directory because this repo has no tests/
|
||||
# tree to exclude: Go tests sit inline beside the code they cover, and the
|
||||
# web suite sits beside its modules. `go build` drops *_test.go outright and
|
||||
# the Vite build never imports a .test.ts, so neither reaches an artifact.
|
||||
#
|
||||
# A commit touching a test AND its source still moves the version — the
|
||||
# source path matches on its own. Only a test-ONLY commit is inert, which is
|
||||
# the whole intent.
|
||||
#
|
||||
# Patterns match what exists today and nothing speculative: there are no
|
||||
# .spec.* files, no __tests__/ directories and no androidTest/ tree. If any
|
||||
# appear they will re-version until named here, which is the harmless
|
||||
# direction and the reason this list is a denylist.
|
||||
':!*_test.go' # 158 files, inline beside the code
|
||||
':!*.test.ts' # 114 files
|
||||
':!*.test.js'
|
||||
':!android/app/src/test' # JVM unit tests; no androidTest tree exists
|
||||
':!web/vitest.config.ts' # test-harness config, not build config
|
||||
':!web/vitest.setup.ts'
|
||||
)
|
||||
|
||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
|
||||
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
||||
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
||||
# on a green run. Realistically this means a shallow clone (no commit in
|
||||
# range touches the shipped set) rather than a repo of pure CI config.
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
echo "version.sh: no commit under '${REF}' touches the shipped file set — shallow clone? (needs fetch-depth: 0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
|
||||
|
||||
if ! name="$(date -u -d "@${commit_epoch}" +%Y.%m.%d.%H%M 2>/dev/null)"; then
|
||||
echo "version.sh: could not read a commit timestamp from '${commit_epoch}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [ "${now_epoch}" -eq "${now_epoch}" ] 2>/dev/null; then
|
||||
echo "version.sh: build timestamp '${now_epoch}' is not a number" >&2
|
||||
exit 1
|
||||
fi
|
||||
code=$(( (now_epoch - EPOCH_2020) / 60 ))
|
||||
|
||||
# Assert the shape here, at the source. A malformed name builds, signs and
|
||||
# publishes perfectly happily; it only surfaces later as an update channel
|
||||
# that has quietly stopped offering anything.
|
||||
if [[ ! "${name}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
|
||||
echo "version.sh: name '${name}' is not YYYY.MM.DD.HHMM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A non-positive key means the build clock is set before 2020, and every
|
||||
# comparison downstream would be nonsense.
|
||||
if [ "${code}" -le 0 ]; then
|
||||
echo "version.sh: ordering key '${code}' is not positive — build clock wrong?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Android's versionCode is a signed 32-bit int and the platform refuses an APK
|
||||
# whose code exceeds it. At ~525k minutes a year this is four thousand years
|
||||
# away in normal operation, so the realistic cause is a build machine with a
|
||||
# badly wrong clock — which produces a code that is not merely too large but
|
||||
# also unreachably high, permanently blocking every real build that follows
|
||||
# from ever outranking it. Cheaper to refuse the build than to discover that
|
||||
# from a phone that will not update.
|
||||
readonly VERSION_CODE_CEILING=2147483647
|
||||
if [ "${code}" -gt "${VERSION_CODE_CEILING}" ]; then
|
||||
echo "version.sh: ordering key '${code}' exceeds versionCode's int32 ceiling — build clock wrong?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEY=VALUE, which is also exactly $GITHUB_OUTPUT's format.
|
||||
echo "name=${name}"
|
||||
echo "code=${code}"
|
||||
echo "tag=v${name}"
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -97,14 +97,16 @@ type tuningSnapshot struct {
|
||||
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||
var out tuningSnapshot
|
||||
out.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)),
|
||||
}
|
||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||
out.Shipped.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()),
|
||||
}
|
||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
@@ -55,6 +56,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
streamSecret: streamSecret,
|
||||
netSettings: netSettings,
|
||||
reacqSettings: reacqSettings,
|
||||
librarySize: recommendation.NewLibrarySize(nil),
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -268,12 +270,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
recSettings *recsettings.Service
|
||||
rng func() float64
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
recSettings *recsettings.Service
|
||||
rng func() float64
|
||||
// librarySize memoises the track count that sizes the candidate pool
|
||||
// (#3880). Held here rather than counted per request: the count is a
|
||||
// full table scan, and library size only moves when a scan runs.
|
||||
librarySize *recommendation.LibrarySize
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
|
||||
@@ -6,19 +6,21 @@ package api
|
||||
// /app/client/ at image build time.
|
||||
//
|
||||
// Both endpoints are authenticated — the bandwidth cost of the APK
|
||||
// (~30-60 MB) makes anonymous access an abuse vector. The Flutter
|
||||
// client's polling only fires after login (banner mounts in the post-
|
||||
// login shell), so this gate is invisible to the actual update flow.
|
||||
// (~30-60 MB) makes anonymous access an abuse vector. The client only
|
||||
// polls after login, so this gate is invisible to the actual update flow.
|
||||
//
|
||||
// /api/client/apk additionally rate-limits per user to a single
|
||||
// download every 60s. Real install flows fire one download per
|
||||
// update; anything tighter is scripted/abusive.
|
||||
//
|
||||
// Returns 404 gracefully when the APK isn't present (dev environments,
|
||||
// pre-CI-wiring); the Flutter client treats 404 as "no update channel
|
||||
// available."
|
||||
// pre-CI-wiring); the client treats 404 as "no update channel available."
|
||||
//
|
||||
// (These paragraphs said "the Flutter client" until 2026-09-10. That client
|
||||
// was deleted in v2026.08.18 — the Android app is the only one now.)
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -84,8 +86,36 @@ func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
// clientVersionSidecar is the JSON written beside the bundled APK by
|
||||
// release.yml. It carries three values that are deliberately separate:
|
||||
//
|
||||
// - Name is a LABEL for people, "YYYY.MM.DD.HHMM" from the commit's
|
||||
// timestamp. Two channels carrying the same code report the same name.
|
||||
// - Code is the ORDERING KEY, minutes since 2020-01-01 at build time, and
|
||||
// is the value Android itself installs by. It answers "may this be
|
||||
// installed over that?" — the name never does.
|
||||
// - Channel is a SIBLING FIELD, never a suffix inside the name.
|
||||
//
|
||||
// JSON rather than a positional line on purpose. The obvious growth path for
|
||||
// the old one-value file was "<name> <code>", which a first-space split
|
||||
// silently mangles the moment a third field appears: the code stops parsing,
|
||||
// and the reader falls back to name comparison WITHOUT erroring.
|
||||
type clientVersionSidecar struct {
|
||||
Name string `json:"name"`
|
||||
// Pointer, not int64: absent must stay distinguishable from zero. An
|
||||
// artifact published before codes were recorded genuinely has no code —
|
||||
// zero would claim it is infinitely old rather than unknown.
|
||||
Code *int64 `json:"code"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
type clientVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Version string `json:"version"`
|
||||
// omitempty on both: the client must be able to tell "this server does
|
||||
// not report a code" from "this build's code is 0", because those call
|
||||
// for different behaviour on the other end.
|
||||
Code *int64 `json:"code,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
APKURL string `json:"apk_url"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
@@ -117,8 +147,25 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var sidecar clientVersionSidecar
|
||||
if err := json.Unmarshal(versionBytes, &sidecar); err != nil {
|
||||
// Fail LOUDLY rather than serving a blank version. The failure mode
|
||||
// this avoids is the one that never gets reported: if an unreadable
|
||||
// sidecar produced an empty name, every client would compare against
|
||||
// nothing, conclude it was current, and go quiet — "I cannot read
|
||||
// this" and "there is nothing newer" would be the same answer.
|
||||
writeErrWithLog(w, h.logger, "client_version: sidecar is not valid JSON", err)
|
||||
return
|
||||
}
|
||||
if sidecar.Name == "" {
|
||||
http.Error(w, `{"error":{"code":"bad_client_version","message":"version sidecar has no name"}}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clientVersionResponse{
|
||||
Version: strings.TrimSpace(string(versionBytes)),
|
||||
Version: strings.TrimSpace(sidecar.Name),
|
||||
Code: sidecar.Code,
|
||||
Channel: strings.TrimSpace(sidecar.Channel),
|
||||
APKURL: "/api/client/apk",
|
||||
SizeBytes: stat.Size(),
|
||||
})
|
||||
|
||||
@@ -77,18 +77,32 @@ func TestClientVersion_404WhenAPKButNoVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
// writeClientAssets stages an APK plus a raw sidecar body, and returns the
|
||||
// APK's size so callers can assert size_bytes without recomputing it.
|
||||
func writeClientAssets(t *testing.T, sidecar string) int64 {
|
||||
t.Helper()
|
||||
dir := withClientAPKDir(t)
|
||||
body := []byte("fake apk content")
|
||||
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte("v2026.05.10\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte(sidecar), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return int64(len(body))
|
||||
}
|
||||
|
||||
func getClientVersion(t *testing.T) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
size := writeClientAssets(t, `{"name":"2026.09.10.1432","code":3523847,"channel":"stable"}`+"\n")
|
||||
rr := getClientVersion(t)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
@@ -96,14 +110,69 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Version != "v2026.05.10" {
|
||||
t.Errorf("version: want trimmed v2026.05.10, got %q", resp.Version)
|
||||
if resp.Version != "2026.09.10.1432" {
|
||||
t.Errorf("version: want 2026.09.10.1432, got %q", resp.Version)
|
||||
}
|
||||
if resp.Code == nil {
|
||||
t.Fatal("code: want 3523847, got absent — the client decides on this, so absent means it silently falls back to name comparison")
|
||||
}
|
||||
if *resp.Code != 3523847 {
|
||||
t.Errorf("code: want 3523847, got %d", *resp.Code)
|
||||
}
|
||||
if resp.Channel != "stable" {
|
||||
t.Errorf("channel: want stable, got %q", resp.Channel)
|
||||
}
|
||||
if resp.APKURL != "/api/client/apk" {
|
||||
t.Errorf("apk_url: want /api/client/apk, got %q", resp.APKURL)
|
||||
}
|
||||
if resp.SizeBytes != int64(len(body)) {
|
||||
t.Errorf("size_bytes: want %d, got %d", len(body), resp.SizeBytes)
|
||||
if resp.SizeBytes != size {
|
||||
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// A release published before ordering keys were recorded has a name and
|
||||
// genuinely no code. That must arrive as ABSENT, not as 0 — zero would claim
|
||||
// the build is infinitely old and offer an update to everyone forever.
|
||||
func TestClientVersion_CodeAbsentIsOmittedNotZero(t *testing.T) {
|
||||
writeClientAssets(t, `{"name":"2026.09.09","code":null,"channel":"stable"}`)
|
||||
rr := getClientVersion(t)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp clientVersionResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Code != nil {
|
||||
t.Errorf("code: want absent, got %d", *resp.Code)
|
||||
}
|
||||
// The wire must omit the key entirely, so a client can distinguish
|
||||
// "this server reports no code" from "this build's code is 0".
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := raw["code"]; present {
|
||||
t.Errorf("code key should be omitted entirely, body was %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The failure this guards is the one nobody reports: if an unreadable sidecar
|
||||
// produced an empty version, every client would compare against nothing,
|
||||
// decide it was current, and go quiet. "I cannot read this" and "there is
|
||||
// nothing newer" must not be the same answer.
|
||||
func TestClientVersion_MalformedSidecarErrorsRatherThanReportingNothing(t *testing.T) {
|
||||
for _, sidecar := range []string{
|
||||
"2026.09.10.1432", // the OLD plain-text format
|
||||
`{"name":"x",`, // truncated JSON
|
||||
`{"code":123,"channel":"dev"}`, // valid JSON, no name
|
||||
"",
|
||||
} {
|
||||
writeClientAssets(t, sidecar)
|
||||
rr := getClientVersion(t)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Errorf("sidecar %q: want an error status, got 200 with body %s", sidecar, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,17 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||
|
||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||
limits := recommendation.DefaultCandidateSourceLimits()
|
||||
// Size the pool to the library (#3880). A fixed ~170 candidates samples a
|
||||
// shrinking fraction of a growing collection, which is what made the
|
||||
// recommendations feel less relevant as the library grew. Degrades to the
|
||||
// base limits if the count is unavailable — never fails the request over a
|
||||
// sizing hint.
|
||||
librarySize := h.librarySize.Get(r.Context(), func(ctx context.Context) (int64, error) {
|
||||
return recommendation.CountLibraryTracks(ctx, q)
|
||||
})
|
||||
limits := recommendation.ScaleForLibrary(
|
||||
recommendation.DefaultCandidateSourceLimits(), librarySize,
|
||||
)
|
||||
candidates, err := recommendation.LoadCandidatesFromSimilarity(
|
||||
r.Context(), q, user.ID, seedID,
|
||||
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
|
||||
@@ -108,7 +118,15 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||
// read per request so an admin change takes effect live.
|
||||
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||
// Diversity caps (#3882). Radio had none while every sibling surface did,
|
||||
// which is how a whole session could come back from one artist. Scaled to
|
||||
// the requested length so a 20-track radio and a 200-track one are capped
|
||||
// alike; Shuffle relaxes them rather than returning a short radio.
|
||||
//
|
||||
// limit-1 because the seed track occupies the first slot and is prepended
|
||||
// below — the caps govern the tracks that FOLLOW it.
|
||||
caps := recommendation.RadioDiversityCaps(limit - 1)
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1, caps)
|
||||
|
||||
out := make([]TrackRef, 0, len(picks)+1)
|
||||
out = append(out, trackRefFrom(track, album.Title, artist.Name))
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Drop the rows the narrower constraints are about to forbid, or re-adding
|
||||
-- them fails against existing data (the 0051 down-migration pattern).
|
||||
DELETE FROM recommendation_weight_profiles WHERE profile = 'songs_like';
|
||||
DELETE FROM recommendation_tuning_audit WHERE scope = 'songs_like';
|
||||
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
|
||||
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
ADD CONSTRAINT recommendation_weight_profiles_profile_check
|
||||
CHECK (profile IN ('radio', 'daily_mix'));
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like
|
||||
-- (Scribe #3881, milestone #398).
|
||||
--
|
||||
-- Songs-like shared the `daily_mix` profile with For-You, and that is the bug.
|
||||
-- The two surfaces want opposite things: For-You is a broad "what will they
|
||||
-- enjoy today", Songs-like answers "what sounds like THIS", and under one set
|
||||
-- of weights the broad answer wins. Operator, 2026-09-10: "I'm expecting to
|
||||
-- get a consistent sound and style from the experience... I was getting a
|
||||
-- seeming wide variety of music from each one when I was hoping to stay in a
|
||||
-- certain neighborhood."
|
||||
--
|
||||
-- Under the shared daily_mix weights, an UNRELATED track the user had liked
|
||||
-- and not played recently scored 1.0 + 2.0 + 1.0 = 4.0 before taste, while a
|
||||
-- PERFECT similarity match they had not liked scored 1.0 + 1.5 = 2.5. Liking
|
||||
-- something outranked sounding like the seed. Splitting the profile is what
|
||||
-- lets similarity dominate here without making For-You narrow.
|
||||
--
|
||||
-- Rows are seeded by the recsettings boot reconcile, not here, so shipped
|
||||
-- defaults live in exactly one place (Go) — same as 0040.
|
||||
|
||||
-- Rule #36: a new value for a CHECK-gated column needs the constraint
|
||||
-- rewritten in the SAME change, or the first row written under the new
|
||||
-- profile fails at runtime rather than at migrate time.
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
ADD CONSTRAINT recommendation_weight_profiles_profile_check
|
||||
CHECK (profile IN ('radio', 'daily_mix', 'songs_like'));
|
||||
|
||||
-- The audit table gates the same name on a separate constraint. Missing this
|
||||
-- one would let the profile be seeded and then fail on the first knob turn —
|
||||
-- green at boot, 500 on first use.
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover', 'songs_like'));
|
||||
@@ -219,7 +219,30 @@ var (
|
||||
// uniform with radio pending trend data.
|
||||
ContextTimeWeight: 1.0,
|
||||
}
|
||||
// Songs-like's own profile (#3881). Pre-push literal only; shipped
|
||||
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
|
||||
// sync with it, exactly as systemMixWeights does above.
|
||||
//
|
||||
// SimilarityWeight dominates here and every seed-INDEPENDENT term is
|
||||
// demoted, which is the whole difference between this surface and For-You.
|
||||
// See ShippedSongsLikeWeights for the property the numbers encode.
|
||||
songsLikeWeights = recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 0.5,
|
||||
RecencyWeight: 0.25,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.05,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 4.0,
|
||||
TasteWeight: 0.25,
|
||||
ContextTimeWeight: 0.5,
|
||||
}
|
||||
systemTasteConfig = taste.DefaultConfig()
|
||||
|
||||
// Sizes the candidate pool to the library (#3880). Cached because the
|
||||
// count is a full table scan and the daily build runs it once per user;
|
||||
// within the TTL every user in a build shares one count.
|
||||
systemLibrarySize = recommendation.NewLibrarySize(nil)
|
||||
)
|
||||
|
||||
// SetSystemMixWeights installs the current daily_mix scoring weights.
|
||||
@@ -230,6 +253,20 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
|
||||
systemMixWeights = w
|
||||
}
|
||||
|
||||
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
|
||||
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
|
||||
// after every knob turn, so a tuning change takes effect on the next daily
|
||||
// build with no restart.
|
||||
//
|
||||
// Separate from systemMixWeights because Songs-like and For-You want opposite
|
||||
// things: For-You roams, Songs-like must not. Sharing one profile is what made
|
||||
// "Songs like X" wander.
|
||||
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
|
||||
systemTuningMu.Lock()
|
||||
defer systemTuningMu.Unlock()
|
||||
songsLikeWeights = w
|
||||
}
|
||||
|
||||
// SetTasteConfig installs the taste-profile build configuration
|
||||
// (half-life + engagement curve, #1250). Same push model as
|
||||
// SetSystemMixWeights.
|
||||
@@ -239,6 +276,12 @@ func SetTasteConfig(c taste.Config) {
|
||||
systemTasteConfig = c
|
||||
}
|
||||
|
||||
func currentSongsLikeWeights() recommendation.ScoringWeights {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
return songsLikeWeights
|
||||
}
|
||||
|
||||
func currentSystemMixWeights() recommendation.ScoringWeights {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
@@ -392,7 +435,13 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN
|
||||
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
||||
// deterministic per (user, day) but rotates across days. Pure — no
|
||||
// truncation, no cap.
|
||||
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate {
|
||||
// weights is a parameter rather than a read of currentSystemMixWeights()
|
||||
// because this sort IS the selection: the caller caps and truncates in the
|
||||
// order this returns, so whatever profile ranks here decides which tracks
|
||||
// reach the playlist. Scoring with daily_mix here and re-scoring with
|
||||
// songs_like afterwards would have let the new profile relabel tracks it had
|
||||
// no part in choosing — inert where it matters (#3881).
|
||||
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate {
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
type scored struct {
|
||||
c recommendation.Candidate
|
||||
@@ -411,7 +460,6 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||
})
|
||||
weights := currentSystemMixWeights()
|
||||
pairs := make([]scored, len(ordered))
|
||||
for i, c := range ordered {
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
|
||||
@@ -632,6 +680,13 @@ func produceSeedMixes(
|
||||
seedPool := pickSeedArtistsFromRows(seedRowsLocal)
|
||||
seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)
|
||||
|
||||
// Once per build rather than once per seed artist — six mixes would
|
||||
// otherwise mean six full-table counts for a number that cannot have
|
||||
// changed between them.
|
||||
librarySize := systemLibrarySize.Get(ctx, func(c context.Context) (int64, error) {
|
||||
return recommendation.CountLibraryTracks(c, q)
|
||||
})
|
||||
|
||||
out := make([]builtPlaylist, 0, len(seeds))
|
||||
for _, artistID := range seeds {
|
||||
artistRow, aerr := q.GetArtistByID(ctx, artistID)
|
||||
@@ -647,23 +702,46 @@ func produceSeedMixes(
|
||||
continue
|
||||
}
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
// Songs-like's own pool shape, not the default (#3881). Same total
|
||||
// size; the composition shifts toward arms that actually measure
|
||||
// distance from the seed. The default gave ~29% of candidates a
|
||||
// sim_score of literally 0.
|
||||
//
|
||||
// Then scaled to the library (#3880): a bigger collection should put
|
||||
// more genuinely-similar candidates in reach, not the same ~170
|
||||
// regardless. The weights still rank sim_score-0 arms last, so the
|
||||
// growth reaches coherence rather than working against it.
|
||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
|
||||
recommendation.DefaultCandidateSourceLimits(),
|
||||
recommendation.ScaleForLibrary(
|
||||
recommendation.SongsLikeCandidateSourceLimits(), librarySize,
|
||||
),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: seed candidates load failed; skipping",
|
||||
"artist_id", uuidStringPL(artistID), "err", cerr)
|
||||
continue
|
||||
}
|
||||
// "Songs like X" excludes X's own songs.
|
||||
filtered := make([]recommendation.Candidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
|
||||
// The seed artist's own songs are ELIGIBLE here, deliberately.
|
||||
//
|
||||
// This used to filter them out — "Songs like X excludes X's own
|
||||
// songs" — which reads as obviously right and is not. The seed is a
|
||||
// TRACK, and the tracks most likely to sound like it are usually the
|
||||
// rest of that artist's catalogue; excluding them threw away the
|
||||
// nearest neighbours of the very thing the mix is built around, and
|
||||
// then reached further out to replace them. On a surface whose whole
|
||||
// job is staying in one neighbourhood, that is backwards.
|
||||
//
|
||||
// Operator, 2026-09-10: "it should also be able to include music from
|
||||
// the same artist."
|
||||
//
|
||||
// Domination is bounded by the cap rather than by exclusion, which is
|
||||
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
|
||||
// inside pickTopN allows at most discoverMaxTracksPerArtist (3) of a
|
||||
// 25-track mix — 12%, a presence rather than a takeover. The seed
|
||||
// track itself still cannot appear; it is passed as an exclusion to
|
||||
// LoadCandidatesFromSimilarity above.
|
||||
tracks := pickTopN(cands, userID, dateStr, now, systemMixLength)
|
||||
if len(tracks) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -838,13 +916,20 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
// truncates to n. Used by Songs-like-X (and as the fallback inside
|
||||
// pickHeadAndTail for small pools).
|
||||
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
|
||||
// only caller, so the switch moves exactly one surface — For-You ranks
|
||||
// through pickHeadAndTail and keeps the broader daily_mix profile.
|
||||
//
|
||||
// The SAME profile does the selection sort and the final score. Passing
|
||||
// one and using the other is the subtle version of this bug: the playlist
|
||||
// would still be chosen by daily_mix and merely wear songs_like numbers.
|
||||
weights := currentSongsLikeWeights()
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
if len(capped) > n {
|
||||
capped = capped[:n]
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
out := make([]rankedCandidate, len(capped))
|
||||
for i, c := range capped {
|
||||
out[i] = rankedCandidate{
|
||||
@@ -873,10 +958,11 @@ func pickHeadAndTail(
|
||||
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
|
||||
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
|
||||
) []rankedCandidate {
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||
// daily_mix — For-You is the broad surface and keeps the roaming profile.
|
||||
weights := currentSystemMixWeights()
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
|
||||
total := headN + tailN
|
||||
if len(capped) <= total {
|
||||
|
||||
@@ -2,8 +2,10 @@ package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -287,6 +289,141 @@ func TestBuildSystemPlaylists_Concurrency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedSharedArtistLibrary seeds artists that genuinely OWN several tracks.
|
||||
//
|
||||
// seedActiveLibrary cannot be used for this: its helper documents that
|
||||
// "artist and album are not deduplicated across calls (mbid-less upsert)",
|
||||
// so every track gets its own artist row and a seed artist always has
|
||||
// exactly one track — the seed itself, which is excluded. A same-artist
|
||||
// assertion against that fixture can never pass no matter what the code
|
||||
// does, which is how the first version of this test failed.
|
||||
//
|
||||
// Albums are not deduplicated either, for the same mbid-less reason, so each
|
||||
// track ends up under its own album row however the titles are written. That
|
||||
// is convenient here rather than a problem: it means the per-ALBUM cap (2)
|
||||
// never binds, and the per-ARTIST cap (3) is unambiguously the thing under
|
||||
// test. Do not "fix" the album titles into something shared without checking
|
||||
// which cap you are then measuring.
|
||||
func seedSharedArtistLibrary(
|
||||
t *testing.T, pool *pgxpool.Pool, name string, numArtists, tracksPerArtist int,
|
||||
) (dbq.User, []pgtype.UUID) {
|
||||
t.Helper()
|
||||
q := dbq.New(pool)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, name)
|
||||
now := time.Now().UTC()
|
||||
dir := t.TempDir()
|
||||
|
||||
artistIDs := make([]pgtype.UUID, 0, numArtists)
|
||||
for a := 0; a < numArtists; a++ {
|
||||
artistName := name + "-shared-" + string(rune('A'+a))
|
||||
ar, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName})
|
||||
if err != nil {
|
||||
t.Fatalf("seed artist: %v", err)
|
||||
}
|
||||
artistIDs = append(artistIDs, ar.ID)
|
||||
|
||||
for k := 0; k < tracksPerArtist; k++ {
|
||||
albumTitle := fmt.Sprintf("%s - Album %d", artistName, k/2)
|
||||
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
|
||||
Title: albumTitle, SortTitle: albumTitle, ArtistID: ar.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed album: %v", err)
|
||||
}
|
||||
tk, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||
Title: fmt.Sprintf("%s-t%d", artistName, k), AlbumID: al.ID, ArtistID: ar.ID,
|
||||
DurationMs: 1000,
|
||||
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d-%d.mp3", name, a, k)),
|
||||
FileSize: 100, FileFormat: "mp3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed track: %v", err)
|
||||
}
|
||||
// Well clear of the recently-played exclusion window, which uses
|
||||
// the DATABASE clock rather than the build's `now`.
|
||||
for pl := 0; pl < 3; pl++ {
|
||||
seedPlayEvent(t, pool, u.ID, tk.ID,
|
||||
now.Add(-time.Duration(24+a*10+k+pl)*time.Hour), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return u, artistIDs
|
||||
}
|
||||
|
||||
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
|
||||
// bounded by the diversity cap rather than excluded outright (#3881).
|
||||
//
|
||||
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
|
||||
// songs" — which threw away the nearest neighbours of the seed TRACK and then
|
||||
// reached further out to replace them. Operator, 2026-09-10: "it should also
|
||||
// be able to include music from the same artist."
|
||||
//
|
||||
// Asserted end-to-end rather than by reading the source, because the guard has
|
||||
// to survive the filter coming back in a different shape — and because an
|
||||
// absence check would now match the comment explaining why the filter is gone.
|
||||
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedSharedArtistLibrary(t, pool, "seedartist", 4, 6)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, t.TempDir()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT count(*) FILTER (WHERE t.artist_id = p.seed_artist_id) AS own,
|
||||
count(*) AS total
|
||||
FROM playlists p
|
||||
JOIN playlist_tracks pt ON pt.playlist_id = p.id
|
||||
JOIN tracks t ON t.id = pt.track_id
|
||||
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
|
||||
GROUP BY p.id
|
||||
`, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// discoverMaxTracksPerArtist, which this package_test cannot reference.
|
||||
// Duplicated deliberately: if the cap moves, this failing is the point.
|
||||
const maxPerArtist = 3
|
||||
|
||||
mixes, withOwn := 0, 0
|
||||
for rows.Next() {
|
||||
var own, total int
|
||||
if err := rows.Scan(&own, &total); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
mixes++
|
||||
if own > 0 {
|
||||
withOwn++
|
||||
}
|
||||
// The bound is what makes inclusion safe. Without it, "include the
|
||||
// seed artist" becomes "the mix is mostly the seed artist", which is
|
||||
// the radio failure (#3882) arriving on a different surface.
|
||||
if own > maxPerArtist {
|
||||
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
|
||||
"out of %d; the per-artist cap (%d) is not being applied",
|
||||
own, total, maxPerArtist)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
|
||||
if mixes == 0 {
|
||||
t.Fatal("no songs_like_artist mixes were built, so this test asserts nothing")
|
||||
}
|
||||
if withOwn == 0 {
|
||||
t.Errorf("none of the %d songs_like mixes contains a single track by its own "+
|
||||
"seed artist — the tracks most likely to sound like the seed are being "+
|
||||
"excluded from the surface whose job is sounding like the seed", mixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
|
||||
@@ -105,6 +105,83 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
}
|
||||
}
|
||||
|
||||
// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}"
|
||||
// (#3881). Same total size as the default (~170) — the composition is what
|
||||
// changes, shifted hard toward arms that actually measure distance from the
|
||||
// seed.
|
||||
//
|
||||
// The surface answers "what sounds like THIS", and it shared the default
|
||||
// pool with For-You, which answers the much broader "what will they enjoy
|
||||
// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by
|
||||
// construction — `taste_overlap` (tracks by the user's top taste artists) and
|
||||
// `random_fill` (literally any track not already in the pool), both of which
|
||||
// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the
|
||||
// seed at all, and the operator saw it: "I was getting a seeming wide variety
|
||||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||||
//
|
||||
// TIERED, per rule 131 — a system mix degrades, it never vanishes:
|
||||
//
|
||||
// tier 1 lb_similar real track-level similarity. The exact promise.
|
||||
// tier 2 similar_artist, tag_overlap, coplay, likes_overlap
|
||||
// seed-RELATED but weaker signal.
|
||||
// tier 3 taste_overlap, random_fill
|
||||
// seed-independent. The floor, and nothing more.
|
||||
//
|
||||
// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3
|
||||
// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||
// everything seed-independent demoted) they rank below any real match and
|
||||
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
||||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||||
//
|
||||
// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those
|
||||
// two arms was the first instinct and it is the vanish-or-nothing shape rule
|
||||
// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage
|
||||
// would produce a short mix or none at all, and "no playlist" is a worse
|
||||
// answer than "a few tracks further from the seed than we would like".
|
||||
//
|
||||
// DO NOT SHRINK AN ARM ORDERED BY UNSEEDED random(). This is the constraint
|
||||
// that shapes the numbers below, and it is not obvious from reading them.
|
||||
//
|
||||
// `likes_overlap` and `random_fill` both end in a bare `ORDER BY random()`
|
||||
// (recommendation.sql:118, :161) with no daily seed. Such an arm returns a
|
||||
// STABLE set only while its LIMIT exceeds the rows eligible for it — at that
|
||||
// point it returns all of them and the random order is irrelevant, because
|
||||
// the caller sorts by id before scoring. Drop the limit below the eligible
|
||||
// count and the arm starts returning a random SUBSET, which differs between
|
||||
// two builds on the same day.
|
||||
//
|
||||
// That is a real defect (#3889) rather than a quirk of this function, and it
|
||||
// bit here: cutting RandomFill to 10 broke
|
||||
// TestBuildSystemPlaylists_DailyNonceDeterminism, whose library is smaller
|
||||
// than the default limit and whose determinism was therefore accidental.
|
||||
// Growing an arm is always safe; only shrinking one is.
|
||||
//
|
||||
// So the seed-independent arms are trimmed only where the ordering is
|
||||
// deterministic: `taste_overlap` sorts by `tpa.weight DESC, t.id` and can be
|
||||
// cut, `random_fill` cannot. The reduction is consequently modest — and it
|
||||
// matters less than it looks, because the WEIGHTS are what demote sim_score-0
|
||||
// candidates now. The pool change biases the draw; the songs_like profile is
|
||||
// what actually keeps unrelated tracks out of the result.
|
||||
//
|
||||
// One arm is left alone that arguably should not be: `likes_overlap` assigns
|
||||
// a FLAT 0.6 sim_score (recommendation.sql:108) rather than measuring
|
||||
// anything — a collaborative signal wearing similarity's clothes, which a
|
||||
// raised SimilarityWeight amplifies. If real ListenBrainz scores commonly
|
||||
// land below 0.6 it will outrank genuine matches. It cannot be trimmed here
|
||||
// without the determinism fix landing first; the honest repair is to stop it
|
||||
// claiming a similarity score it never computed (#3879).
|
||||
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
||||
SimilarArtist: 40, // tier 2 — raised; growing is always safe
|
||||
TagOverlap: 20, // tier 2
|
||||
UserCoplay: 20, // tier 2
|
||||
LikesOverlap: 20, // tier 2 — NOT trimmed: unseeded random(), see above
|
||||
TasteOverlap: 10, // tier 3 floor — halved; deterministic ordering, safe
|
||||
RandomFill: 30, // tier 3 floor — NOT trimmed: unseeded random(), see above
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
||||
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
|
||||
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Scaling the candidate pool to the library (#3880).
|
||||
//
|
||||
// DefaultCandidateSourceLimits returns what its own comment calls "the v1
|
||||
// hardcoded constants per spec" — ~170 candidates, identical for a 500-track
|
||||
// library and a 100,000-track one. Operator, 2026-09-10: "is the pool that we
|
||||
// draw from somehow scaled to the amount of music in the library... my earlier
|
||||
// understanding of the tuning and work may have been skewed by what was in my
|
||||
// library."
|
||||
//
|
||||
// It was not, and the consequence compounds. The pool samples a shrinking
|
||||
// FRACTION of the library as it grows — 17% of 1,000 tracks, 1.7% of 10,000,
|
||||
// 0.17% of 100,000 — so the ceiling on how much of a collection can ever
|
||||
// surface stays flat while the collection does not. RandomFill, whose entire
|
||||
// job is exploration, becomes a thinner and noisier slice of a more diverse
|
||||
// corpus at exactly the moment diversity rises.
|
||||
|
||||
const (
|
||||
// libraryScaleReference is the library size at which the base limits
|
||||
// apply unchanged. Below it nothing scales, so small libraries keep
|
||||
// today's behaviour exactly.
|
||||
//
|
||||
// ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned
|
||||
// against is unrecorded; 5,000 is a plausible mid-size library and a
|
||||
// deliberately conservative place to start growing. #3879 should replace
|
||||
// this with the real number, and until it does, this constant is the
|
||||
// single thing to change.
|
||||
libraryScaleReference = 5000
|
||||
|
||||
// maxLibraryScale bounds growth so a very large library does not turn
|
||||
// every recommendation query into a huge scan. At 4x the pool tops out
|
||||
// around 500 candidates, which is still cheap to score in memory.
|
||||
maxLibraryScale = 4.0
|
||||
)
|
||||
|
||||
// libraryScale is sqrt rather than linear on purpose. Linear growth would put
|
||||
// a 100,000-track library at a 3,400-candidate pool — a slow query, slow
|
||||
// scoring, and far past the point where more candidates improve the answer.
|
||||
// Square-root growth keeps the pool meaningfully proportional while staying
|
||||
// bounded: 1x at the reference, 2x at four times it, 4x at sixteen times.
|
||||
//
|
||||
// Never returns below 1: the base limits are a floor, not a midpoint. That
|
||||
// also keeps this safe against #3889 — growing an arm ordered by unseeded
|
||||
// random() is fine, shrinking one is what breaks same-day determinism.
|
||||
func libraryScale(libraryTracks int64) float64 {
|
||||
if libraryTracks <= libraryScaleReference {
|
||||
return 1.0
|
||||
}
|
||||
f := math.Sqrt(float64(libraryTracks) / float64(libraryScaleReference))
|
||||
return math.Min(f, maxLibraryScale)
|
||||
}
|
||||
|
||||
// ScaleForLibrary grows the library-bounded arms of a limit set for a library
|
||||
// of the given size, and leaves the rest alone.
|
||||
//
|
||||
// THE SCALING IS NOT UNIFORM, and that is the substance of it rather than a
|
||||
// refinement. An arm's limit only matters if there are rows for it to cut off,
|
||||
// so what an arm is BOUNDED BY decides whether library size can help it:
|
||||
//
|
||||
// scaled LBSimilar, SimilarArtist, TagOverlap — bounded by similarity
|
||||
// and tag data, which grows as the library does. A bigger library
|
||||
// means more of ListenBrainz's returned MBIDs survive the
|
||||
// local-library filter in similarity/worker.go.
|
||||
// scaled RandomFill — the exploration arm, and the one the complaint is
|
||||
// really about. This is where "fraction of the library" lives.
|
||||
// unscaled LikesOverlap — bounded by the USER's likes.
|
||||
// unscaled UserCoplay — bounded by the INSTANCE's co-play graph.
|
||||
// unscaled TasteOverlap — bounded by the taste profile's artists.
|
||||
//
|
||||
// Raising the last three would not sample more of the library; it would
|
||||
// sample more of a set that did not grow, which is churn rather than reach.
|
||||
// Leaving TasteOverlap alone has a second benefit: it and RandomFill are the
|
||||
// two arms carrying sim_score 0, so this does not inflate the
|
||||
// seed-independent share as fast as a uniform scale would.
|
||||
func ScaleForLibrary(base CandidateSourceLimits, libraryTracks int64) CandidateSourceLimits {
|
||||
f := libraryScale(libraryTracks)
|
||||
grow := func(n int) int { return int(float64(n) * f) } // f >= 1, so never shrinks
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: grow(base.LBSimilar),
|
||||
SimilarArtist: grow(base.SimilarArtist),
|
||||
TagOverlap: grow(base.TagOverlap),
|
||||
RandomFill: grow(base.RandomFill),
|
||||
LikesOverlap: base.LikesOverlap,
|
||||
UserCoplay: base.UserCoplay,
|
||||
TasteOverlap: base.TasteOverlap,
|
||||
}
|
||||
}
|
||||
|
||||
// CountLibraryTracks returns the whole library's track count.
|
||||
//
|
||||
// Reuses CountTracksMatching with an empty pattern — `title ILIKE '%%'`
|
||||
// matches every row — rather than adding a query, because a new one would
|
||||
// need sqlc regeneration (rule 28, and see #3889 for where that blocks).
|
||||
// The user id is left invalid so the quarantine anti-join is skipped: this
|
||||
// number sizes a pool, and a per-user view of it would be false precision.
|
||||
func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
|
||||
return q.CountTracksMatching(ctx, dbq.CountTracksMatchingParams{
|
||||
Column1: "",
|
||||
UserID: pgtype.UUID{}, // invalid → NULL → no quarantine filter
|
||||
})
|
||||
}
|
||||
|
||||
// librarySizeTTL is how long a counted size is reused. Library size changes
|
||||
// only when a scan runs, so minutes are plenty — and the count is a full
|
||||
// table scan (the ILIKE defeats every index), which is why it is not done
|
||||
// per request.
|
||||
const librarySizeTTL = 5 * time.Minute
|
||||
|
||||
// librarySizeTimeout bounds the count itself — see loadWithDeadline.
|
||||
const librarySizeTimeout = 3 * time.Second
|
||||
|
||||
// LibrarySize memoises the library track count.
|
||||
//
|
||||
// Degrades rather than fails: a count that errors or times out leaves the
|
||||
// previous value in place, and a zero (never yet counted) scales to the base
|
||||
// limits — which is exactly today's behaviour. Nothing about sizing a
|
||||
// candidate pool justifies failing the request it is sizing.
|
||||
type LibrarySize struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time // injectable for tests
|
||||
at time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// NewLibrarySize returns an empty cache. The zero value works too; this
|
||||
// exists so tests can pin the clock.
|
||||
func NewLibrarySize(now func() time.Time) *LibrarySize {
|
||||
return &LibrarySize{now: now}
|
||||
}
|
||||
|
||||
// Get returns the cached size, refreshing through load when stale.
|
||||
//
|
||||
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
|
||||
// just not memoised. That is deliberate rather than defensive habit. The api
|
||||
// handlers struct is built directly by a dozen tests that cannot know about
|
||||
// every field, and a nil here previously panicked inside a radio request —
|
||||
// turning a missing pool-sizing HINT into a 500. Uncached-but-correct is the
|
||||
// right failure for something whose whole contract is that it degrades.
|
||||
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
|
||||
if l == nil {
|
||||
n, err := loadWithDeadline(ctx, load)
|
||||
if err != nil {
|
||||
return 0 // scales to the base limits
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now
|
||||
if l.now != nil {
|
||||
now = l.now
|
||||
}
|
||||
if !l.at.IsZero() && now().Sub(l.at) < librarySizeTTL {
|
||||
return l.size
|
||||
}
|
||||
|
||||
n, err := loadWithDeadline(ctx, load)
|
||||
if err != nil {
|
||||
// Keep the last known value and re-try at the next call rather than
|
||||
// stamping `at`, so a transient failure does not pin a stale number
|
||||
// for the whole TTL.
|
||||
return l.size
|
||||
}
|
||||
l.size, l.at = n, now()
|
||||
return l.size
|
||||
}
|
||||
|
||||
// loadWithDeadline bounds the count. Rule 156: the caller is a user waiting
|
||||
// on a radio, and a pool-sizing hint is never worth hanging for.
|
||||
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
|
||||
defer cancel()
|
||||
return load(cctx)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Small libraries must behave exactly as before. Scaling is meant to help a
|
||||
// growing collection, not to change what every existing install already does.
|
||||
func TestScaleForLibrary_SmallLibrariesAreUntouched(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
for _, size := range []int64{0, 1, 500, libraryScaleReference} {
|
||||
if got := ScaleForLibrary(base, size); got != base {
|
||||
t.Errorf("library of %d changed the limits: %+v, want %+v", size, got, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The complaint, restated as a property: a bigger library must reach further
|
||||
// into itself. Operator, 2026-09-10 — "is the pool that we draw from somehow
|
||||
// scaled to the amount of music in the library".
|
||||
func TestScaleForLibrary_BigLibrariesGetABiggerPool(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
small := ScaleForLibrary(base, libraryScaleReference)
|
||||
big := ScaleForLibrary(base, libraryScaleReference*16)
|
||||
|
||||
if big.RandomFill <= small.RandomFill {
|
||||
t.Errorf("RandomFill %d did not grow for a 16x library (was %d); that arm "+
|
||||
"IS the fraction-of-library problem", big.RandomFill, small.RandomFill)
|
||||
}
|
||||
if big.LBSimilar <= small.LBSimilar {
|
||||
t.Errorf("LBSimilar %d did not grow for a 16x library (was %d)",
|
||||
big.LBSimilar, small.LBSimilar)
|
||||
}
|
||||
}
|
||||
|
||||
// THE SUBSTANCE: scaling is per-arm, decided by what each arm is BOUNDED BY.
|
||||
// Raising a limit only helps if there are rows for it to cut off, so arms
|
||||
// bounded by the user's likes, the instance's co-play graph or the taste
|
||||
// profile gain nothing from a bigger library — raising them would sample more
|
||||
// of a set that did not grow.
|
||||
//
|
||||
// A uniform scale would look right and be wrong, which is why this is pinned
|
||||
// separately from "the pool got bigger".
|
||||
func TestScaleForLibrary_OnlyLibraryBoundedArmsGrow(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
big := ScaleForLibrary(base, libraryScaleReference*16)
|
||||
|
||||
for _, tc := range []struct {
|
||||
arm string
|
||||
got, want int
|
||||
why string
|
||||
}{
|
||||
{"LikesOverlap", big.LikesOverlap, base.LikesOverlap, "bounded by the user's likes"},
|
||||
{"UserCoplay", big.UserCoplay, base.UserCoplay, "bounded by the instance's co-play graph"},
|
||||
{"TasteOverlap", big.TasteOverlap, base.TasteOverlap, "bounded by the taste profile's artists"},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s scaled to %d (base %d) but is %s — a bigger library gives it "+
|
||||
"nothing more to return", tc.arm, tc.got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Growth is bounded, or a huge library turns every recommendation query into
|
||||
// a huge scan. sqrt keeps it proportional; the ceiling keeps it affordable.
|
||||
func TestScaleForLibrary_GrowthIsBounded(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
huge := ScaleForLibrary(base, 100_000_000)
|
||||
|
||||
if huge.RandomFill > int(float64(base.RandomFill)*maxLibraryScale) {
|
||||
t.Errorf("RandomFill %d exceeds the %.0fx ceiling on base %d",
|
||||
huge.RandomFill, maxLibraryScale, base.RandomFill)
|
||||
}
|
||||
// sqrt, not linear — and the test point matters. At SIXTEEN times the
|
||||
// reference both curves land on 4x, because linear has already been
|
||||
// clamped by the ceiling; asserting there proves nothing. Four times the
|
||||
// reference is below the ceiling for both, so the curves separate: sqrt
|
||||
// gives 2x, linear would give 4x.
|
||||
four := ScaleForLibrary(base, libraryScaleReference*4)
|
||||
if four.RandomFill != base.RandomFill*2 {
|
||||
t.Errorf("4x library gave RandomFill %d, want %d — sqrt growth (linear "+
|
||||
"would give %d)", four.RandomFill, base.RandomFill*2, base.RandomFill*4)
|
||||
}
|
||||
}
|
||||
|
||||
// Never below the base. The base limits are a floor, not a midpoint — and
|
||||
// #3889 makes this load-bearing rather than tidy: shrinking an arm ordered by
|
||||
// unseeded random() changes pool membership between same-day rebuilds.
|
||||
func TestScaleForLibrary_NeverShrinksAnArm(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
for _, size := range []int64{0, 1, 100, 4999, 5001, 1_000_000} {
|
||||
got := ScaleForLibrary(base, size)
|
||||
for _, tc := range []struct {
|
||||
arm string
|
||||
got, want int
|
||||
}{
|
||||
{"LBSimilar", got.LBSimilar, base.LBSimilar},
|
||||
{"SimilarArtist", got.SimilarArtist, base.SimilarArtist},
|
||||
{"TagOverlap", got.TagOverlap, base.TagOverlap},
|
||||
{"RandomFill", got.RandomFill, base.RandomFill},
|
||||
{"LikesOverlap", got.LikesOverlap, base.LikesOverlap},
|
||||
{"UserCoplay", got.UserCoplay, base.UserCoplay},
|
||||
{"TasteOverlap", got.TasteOverlap, base.TasteOverlap},
|
||||
} {
|
||||
if tc.got < tc.want {
|
||||
t.Errorf("library %d shrank %s to %d (base %d)", size, tc.arm, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A pool-sizing hint must never fail the request it is sizing. A count that
|
||||
// errors leaves the previous value in place, and a never-counted cache
|
||||
// returns 0 — which scales to the base limits, i.e. exactly today's
|
||||
// behaviour.
|
||||
func TestLibrarySize_DegradesOnFailure(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
|
||||
boom := func(context.Context) (int64, error) { return 0, errors.New("db is down") }
|
||||
if got := c.Get(context.Background(), boom); got != 0 {
|
||||
t.Errorf("first failure returned %d, want 0 (which scales to the base limits)", got)
|
||||
}
|
||||
if got := ScaleForLibrary(DefaultCandidateSourceLimits(), 0); got != DefaultCandidateSourceLimits() {
|
||||
t.Error("a zero library size did not scale to the base limits")
|
||||
}
|
||||
|
||||
// A good count, then a failure: the last known value survives.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 40_000, nil }); got != 40_000 {
|
||||
t.Fatalf("got %d, want 40000", got)
|
||||
}
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
if got := c.Get(context.Background(), boom); got != 40_000 {
|
||||
t.Errorf("a failed refresh returned %d, discarding the last known 40000", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The count is a full table scan, so it must not run per request.
|
||||
func TestLibrarySize_CachesWithinTheTTL(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
calls := 0
|
||||
load := func(context.Context) (int64, error) { calls++; return 1234, nil }
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
c.Get(context.Background(), load)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("counted %d times within the TTL, want 1 — this is a full table scan", calls)
|
||||
}
|
||||
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
c.Get(context.Background(), load)
|
||||
if calls != 2 {
|
||||
t.Errorf("counted %d times after the TTL expired, want 2 — the size never refreshes", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A transient failure must not pin a stale value for the whole TTL: the
|
||||
// failed refresh does not stamp the clock, so the next call retries.
|
||||
func TestLibrarySize_RetriesAfterAFailedRefresh(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { return 100, nil })
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { return 0, errors.New("blip") })
|
||||
|
||||
// Immediately after, with no clock movement — a stamped failure would
|
||||
// serve the stale 100 until the TTL expired again.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 900, nil }); got != 900 {
|
||||
t.Errorf("got %d after a failed refresh, want 900 — the failure pinned a stale value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil cache must not panic, and must still be CORRECT — uncached, not
|
||||
// broken.
|
||||
//
|
||||
// This is not a hypothetical hardening. internal/api builds its handlers
|
||||
// struct directly in a dozen tests, none of which know about every field, so
|
||||
// librarySize arrives nil there. The first version of this panicked inside
|
||||
// handleRadio and took down TestHandleRadio_ColdStart_OnlySeedReturned with a
|
||||
// SIGSEGV — turning a missing pool-sizing HINT into a request-killing crash,
|
||||
// which is the opposite of what a value that "degrades rather than fails" is
|
||||
// supposed to do.
|
||||
func TestLibrarySize_NilReceiverStillCounts(t *testing.T) {
|
||||
var c *LibrarySize // deliberately not constructed
|
||||
|
||||
calls := 0
|
||||
got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
||||
calls++
|
||||
return 40_000, nil
|
||||
})
|
||||
if got != 40_000 {
|
||||
t.Errorf("nil cache returned %d, want 40000 — it should still count, just not memoise", got)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("nil cache called the loader %d times, want 1", calls)
|
||||
}
|
||||
|
||||
// Uncached: a second call counts again rather than reusing anything.
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { calls++; return 40_000, nil })
|
||||
if calls != 2 {
|
||||
t.Errorf("nil cache memoised across calls (%d loads); it has nowhere to store a value", calls)
|
||||
}
|
||||
|
||||
// And it still degrades on error rather than panicking.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
||||
return 0, errors.New("db is down")
|
||||
}); got != 0 {
|
||||
t.Errorf("nil cache returned %d on a failed count, want 0 (base limits)", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
@@ -13,15 +15,72 @@ type Candidate struct {
|
||||
Inputs ScoringInputs
|
||||
}
|
||||
|
||||
// Shuffle scores each candidate, sorts descending by score, and returns
|
||||
// the top `limit` candidates. limit <= 0 returns nil; nil input returns
|
||||
// nil. Pure — no IO, no global state beyond the rng callback.
|
||||
// DiversityCaps bounds how much of one selection a single artist or album
|
||||
// may occupy. A zero value means "no cap", which is what Shuffle did
|
||||
// unconditionally before #3882.
|
||||
type DiversityCaps struct {
|
||||
MaxPerArtist int
|
||||
MaxPerAlbum int
|
||||
}
|
||||
|
||||
// radioCapArtistPer25 / radioCapAlbumPer25 hold the caps at the same
|
||||
// PROPORTION the system mixes already use — 3 artist / 2 album tracks in a
|
||||
// 25-track mix — so a 20-track radio and a 200-track one feel alike rather
|
||||
// than one of them being effectively uncapped.
|
||||
//
|
||||
// A fixed count cannot do that. Three-per-artist is a reasonable 12% of a
|
||||
// 25-track mix and an absurd 1.5% of a 200-track radio, where it would put
|
||||
// the selection permanently in the relaxation path below and quietly undo
|
||||
// the cap it was meant to enforce.
|
||||
const (
|
||||
radioCapArtistPer25 = 3
|
||||
radioCapAlbumPer25 = 2
|
||||
)
|
||||
|
||||
// RadioDiversityCaps scales the diversity caps to the requested radio
|
||||
// length. Floors of 2 and 1 keep a very short radio from being capped into
|
||||
// a single track per artist, which would be its own kind of wrong.
|
||||
func RadioDiversityCaps(limit int) DiversityCaps {
|
||||
artist := limit * radioCapArtistPer25 / 25
|
||||
if artist < 2 {
|
||||
artist = 2
|
||||
}
|
||||
album := limit * radioCapAlbumPer25 / 25
|
||||
if album < 1 {
|
||||
album = 1
|
||||
}
|
||||
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
|
||||
}
|
||||
|
||||
// Shuffle scores each candidate, sorts descending by score, and returns the
|
||||
// top `limit` candidates, preferring artist/album diversity. limit <= 0
|
||||
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
|
||||
// the rng callback.
|
||||
//
|
||||
// THE COUNT IS NEVER REDUCED BY THE CAPS. Selection runs in two passes: the
|
||||
// first takes candidates that fit under the caps, and the second fills any
|
||||
// remaining slots from those the first pass skipped, still in score order.
|
||||
// So the result holds min(limit, len(candidates)) either way — the caps
|
||||
// change WHICH tracks are chosen, never HOW MANY.
|
||||
//
|
||||
// That two-pass shape is the whole design, and a hard cap would have been
|
||||
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
|
||||
// pool concentrated on a few artists would return six tracks and call it a
|
||||
// radio. Rule 131's principle — degrade, never vanish — applies past the
|
||||
// system mixes it was written for.
|
||||
//
|
||||
// Before #3882 there was no cap here at all, while discover.go,
|
||||
// you_might_like.go and home.go all had one. That asymmetry is what let a
|
||||
// radio session come back entirely from a single artist: nothing between
|
||||
// the pool and the output bounded any artist's share, so a pool dominated
|
||||
// by one artist produced an output dominated by it too.
|
||||
func Shuffle(
|
||||
candidates []Candidate,
|
||||
weights ScoringWeights,
|
||||
now time.Time,
|
||||
rng func() float64,
|
||||
limit int,
|
||||
caps DiversityCaps,
|
||||
) []Candidate {
|
||||
if len(candidates) == 0 || limit <= 0 {
|
||||
return nil
|
||||
@@ -40,9 +99,36 @@ func Shuffle(
|
||||
if limit > len(scored) {
|
||||
limit = len(scored)
|
||||
}
|
||||
out := make([]Candidate, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
out[i] = scored[i].c
|
||||
|
||||
out := make([]Candidate, 0, limit)
|
||||
deferred := make([]Candidate, 0, len(scored)-limit)
|
||||
artistCount := map[pgtype.UUID]int{}
|
||||
albumCount := map[pgtype.UUID]int{}
|
||||
|
||||
for _, s := range scored {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
||||
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
|
||||
if overArtist || overAlbum {
|
||||
// Held back, not discarded — pass two may still need it.
|
||||
deferred = append(deferred, s.c)
|
||||
continue
|
||||
}
|
||||
artistCount[s.c.Track.ArtistID]++
|
||||
albumCount[s.c.Track.AlbumID]++
|
||||
out = append(out, s.c)
|
||||
}
|
||||
|
||||
// Pass two: the caps could not fill the request, so relax them rather
|
||||
// than hand back a short radio. Still score order, so the best of the
|
||||
// held-back candidates go first.
|
||||
for _, c := range deferred {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// candBy builds a candidate with a real artist and album identity, which
|
||||
// `cand` deliberately leaves zero.
|
||||
func candBy(t *testing.T, id, artist, album string, in ScoringInputs) Candidate {
|
||||
t.Helper()
|
||||
tr := dbq.Track{Title: id}
|
||||
if err := tr.ID.Scan("00000000-0000-0000-0000-" + id); err != nil {
|
||||
t.Fatalf("track id %q: %v", id, err)
|
||||
}
|
||||
if err := tr.ArtistID.Scan("00000000-0000-0000-0001-" + artist); err != nil {
|
||||
t.Fatalf("artist id %q: %v", artist, err)
|
||||
}
|
||||
if err := tr.AlbumID.Scan("00000000-0000-0000-0002-" + album); err != nil {
|
||||
t.Fatalf("album id %q: %v", album, err)
|
||||
}
|
||||
return Candidate{Track: tr, Inputs: in}
|
||||
}
|
||||
|
||||
// artistKey is the map key artistsOf produces for a given fixture artist,
|
||||
// derived the same way the fixture builds the UUID. Hand-writing the hex is
|
||||
// how the first version of this test went wrong: the artist id is not all
|
||||
// zeros — it carries 0001 in its fourth group — so the literal did not match
|
||||
// and the assertion measured nothing.
|
||||
func artistKey(t *testing.T, artist string) string {
|
||||
t.Helper()
|
||||
var id pgtype.UUID
|
||||
if err := id.Scan("00000000-0000-0000-0001-" + artist); err != nil {
|
||||
t.Fatalf("artist id %q: %v", artist, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", id.Bytes)
|
||||
}
|
||||
|
||||
// artistsOf counts how many picks each artist contributed.
|
||||
func artistsOf(picks []Candidate) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, p := range picks {
|
||||
out[fmt.Sprintf("%x", p.Track.ArtistID.Bytes)]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// THE BUG. Operator, 2026-09-10: started radio from a song and "literally
|
||||
// all of the songs in the playlist after that were from a single artist".
|
||||
//
|
||||
// One artist's tracks all outscore everything else, and there are more of
|
||||
// them than the radio has room for. Without a cap the output is entirely
|
||||
// that artist — nothing between the pool and the result bounded its share.
|
||||
func TestShuffle_CapStopsOneArtistTakingTheWholeRadio(t *testing.T) {
|
||||
var cs []Candidate
|
||||
// 20 tracks by artist A, all liked so they sort to the top.
|
||||
for i := 0; i < 20; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
|
||||
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
|
||||
}
|
||||
// 10 tracks by 10 other artists, none liked, so they all rank below.
|
||||
for i := 0; i < 10; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
|
||||
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
|
||||
ScoringInputs{IsGeneralLiked: false}))
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
caps := DiversityCaps{MaxPerArtist: 3}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, caps)
|
||||
|
||||
if len(picks) != limit {
|
||||
t.Fatalf("len = %d, want %d", len(picks), limit)
|
||||
}
|
||||
byArtist := artistsOf(picks)
|
||||
artistA := artistKey(t, "00000000000a")
|
||||
if got := byArtist[artistA]; got != 3 {
|
||||
t.Errorf("artist A contributed %d of %d picks, cap is 3", got, limit)
|
||||
}
|
||||
if len(byArtist) < 8 {
|
||||
t.Errorf("only %d distinct artists in a %d-track radio; the cap is not "+
|
||||
"spreading the selection", len(byArtist), limit)
|
||||
}
|
||||
|
||||
// The same pool with NO cap is the regression this exists to catch. If
|
||||
// this stops holding, the test above is no longer proving anything.
|
||||
uncapped := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, DiversityCaps{})
|
||||
if artistsOf(uncapped)[artistA] != limit {
|
||||
t.Errorf("uncapped selection was not single-artist, so this fixture no " +
|
||||
"longer reproduces the bug being fixed")
|
||||
}
|
||||
}
|
||||
|
||||
// A CAP IS A BOUND, NOT AN EXCLUSION. The operator asked for the opposite of
|
||||
// removal: "again it should be able to add songs from the same artist."
|
||||
func TestShuffle_CappedArtistIsStillRepresented(t *testing.T) {
|
||||
var cs []Candidate
|
||||
for i := 0; i < 20; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
|
||||
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
|
||||
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
|
||||
ScoringInputs{IsGeneralLiked: false}))
|
||||
}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10,
|
||||
DiversityCaps{MaxPerArtist: 3})
|
||||
if artistsOf(picks)[artistKey(t, "00000000000a")] == 0 {
|
||||
t.Error("the dominant artist was excluded entirely; the cap should bound " +
|
||||
"its share, not remove it")
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 131, applied past the system mixes it was written for: degrade, never
|
||||
// vanish. A hard cap over a pool with few artists would hand back a six-track
|
||||
// "radio" for a fifty-track request. The caps must change WHICH tracks are
|
||||
// picked, never HOW MANY.
|
||||
func TestShuffle_CapsNeverShortenTheResult(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
artists int
|
||||
perArt int
|
||||
limit int
|
||||
}{
|
||||
{"one artist owns the entire pool", 1, 30, 10},
|
||||
{"two artists, tight cap", 2, 15, 20},
|
||||
{"pool smaller than the request", 3, 2, 50},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cs []Candidate
|
||||
n := 0
|
||||
for a := 0; a < tc.artists; a++ {
|
||||
for k := 0; k < tc.perArt; k++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", n),
|
||||
fmt.Sprintf("%012d", 300+a), fmt.Sprintf("%012d", n),
|
||||
ScoringInputs{}))
|
||||
n++
|
||||
}
|
||||
}
|
||||
want := tc.limit
|
||||
if len(cs) < want {
|
||||
want = len(cs)
|
||||
}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), tc.limit,
|
||||
DiversityCaps{MaxPerArtist: 3, MaxPerAlbum: 2})
|
||||
if len(picks) != want {
|
||||
t.Errorf("len = %d, want %d — the caps shortened the result instead "+
|
||||
"of relaxing to fill it", len(picks), want)
|
||||
}
|
||||
// No duplicates: a candidate deferred in pass one must not also be
|
||||
// taken in pass two.
|
||||
seen := map[[16]byte]bool{}
|
||||
for _, p := range picks {
|
||||
if seen[p.Track.ID.Bytes] {
|
||||
t.Fatalf("track %x appears twice; pass two re-added a pick", p.Track.ID.Bytes)
|
||||
}
|
||||
seen[p.Track.ID.Bytes] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A fixed count cannot serve both a 20-track radio and a 200-track one: three
|
||||
// per artist is 12% of the first and 1.5% of the second, which would leave the
|
||||
// long radio permanently in the relaxation path and effectively uncapped.
|
||||
func TestRadioDiversityCaps_ScaleWithTheRequestedLength(t *testing.T) {
|
||||
short := RadioDiversityCaps(25)
|
||||
long := RadioDiversityCaps(200)
|
||||
|
||||
if short.MaxPerArtist != 3 {
|
||||
t.Errorf("a 25-track radio caps artists at %d, want 3 — the same "+
|
||||
"proportion the system mixes use", short.MaxPerArtist)
|
||||
}
|
||||
if long.MaxPerArtist <= short.MaxPerArtist {
|
||||
t.Errorf("a 200-track radio caps artists at %d, no higher than a 25-track "+
|
||||
"one at %d; the cap is not scaling", long.MaxPerArtist, short.MaxPerArtist)
|
||||
}
|
||||
// Proportion held, not just "bigger".
|
||||
if long.MaxPerArtist != 24 {
|
||||
t.Errorf("200-track artist cap = %d, want 24 (3 per 25)", long.MaxPerArtist)
|
||||
}
|
||||
|
||||
// Floors: a tiny radio must not be capped down to one track per artist.
|
||||
tiny := RadioDiversityCaps(1)
|
||||
if tiny.MaxPerArtist < 2 {
|
||||
t.Errorf("tiny radio artist cap = %d, want at least 2", tiny.MaxPerArtist)
|
||||
}
|
||||
if tiny.MaxPerAlbum < 1 {
|
||||
t.Errorf("tiny radio album cap = %d, want at least 1", tiny.MaxPerAlbum)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestShuffle_LikedRanksAboveUnliked(t *testing.T) {
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Errorf("liked track did not rank first: %+v", out)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestShuffle_HighSkipRanksLast(t *testing.T) {
|
||||
cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0
|
||||
cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" {
|
||||
t.Errorf("skip-ratio ordering broken: %v", titles(out))
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestShuffle_LimitTruncates(t *testing.T) {
|
||||
for i := range cs {
|
||||
cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{})
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if len(out) != 10 {
|
||||
t.Errorf("len = %d, want 10", len(out))
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out))
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShuffle_Empty_ReturnsEmpty(t *testing.T) {
|
||||
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(out))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package recommendation
|
||||
|
||||
import "testing"
|
||||
|
||||
// Songs-like's pool must lean on arms that MEASURE distance from the seed.
|
||||
// The default gave ~29% of candidates a sim_score of literally 0
|
||||
// (taste_overlap and random_fill are both `0.0::float8` in
|
||||
// recommendation.sql), which is what let "Songs like X" wander.
|
||||
func TestSongsLikeLimits_FavourTheArmsThatMeasureTheSeed(t *testing.T) {
|
||||
d := DefaultCandidateSourceLimits()
|
||||
s := SongsLikeCandidateSourceLimits()
|
||||
|
||||
if s.LBSimilar <= d.LBSimilar {
|
||||
t.Errorf("LBSimilar %d is not above the default %d — the only arm that "+
|
||||
"measures track-level distance from the seed should be favoured here",
|
||||
s.LBSimilar, d.LBSimilar)
|
||||
}
|
||||
// The two seed-INDEPENDENT arms, which is the whole complaint.
|
||||
zeroSimDefault := d.TasteOverlap + d.RandomFill
|
||||
zeroSimSongsLike := s.TasteOverlap + s.RandomFill
|
||||
if zeroSimSongsLike >= zeroSimDefault {
|
||||
t.Errorf("seed-independent arms total %d, not reduced from the default %d; "+
|
||||
"these carry sim_score 0 by construction", zeroSimSongsLike, zeroSimDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 131: a system playlist degrades, it never vanishes.
|
||||
//
|
||||
// Zeroing the seed-independent arms was the first instinct and is exactly the
|
||||
// vanish-or-nothing shape that rule forbids: a seed whose artist has thin
|
||||
// ListenBrainz coverage would yield a short mix or none at all. They are the
|
||||
// tier-3 FLOOR — reduced hard, never removed — and the songs_like weights are
|
||||
// what keep them at the bottom of the ranking rather than out of the pool.
|
||||
//
|
||||
// "A few tracks further from the seed than we would like" beats "no playlist".
|
||||
func TestSongsLikeLimits_KeepATierThreeFloor(t *testing.T) {
|
||||
s := SongsLikeCandidateSourceLimits()
|
||||
if s.RandomFill <= 0 {
|
||||
t.Error("RandomFill is zero: a seed with thin similarity coverage now produces " +
|
||||
"a short or empty mix instead of degrading (rule 131)")
|
||||
}
|
||||
if s.TasteOverlap <= 0 {
|
||||
t.Error("TasteOverlap is zero: the graded floor is gone, leaving only random " +
|
||||
"fill between a sparse seed and an empty playlist (rule 131)")
|
||||
}
|
||||
}
|
||||
|
||||
// The pool should stay roughly the size it was — this change is about
|
||||
// COMPOSITION, not about starving the surface. A much smaller pool would also
|
||||
// shrink what the per-artist cap has to work with.
|
||||
func TestSongsLikeLimits_KeepThePoolRoughlyTheSameSize(t *testing.T) {
|
||||
total := func(l CandidateSourceLimits) int {
|
||||
return l.LBSimilar + l.SimilarArtist + l.TagOverlap + l.LikesOverlap +
|
||||
l.RandomFill + l.TasteOverlap + l.UserCoplay
|
||||
}
|
||||
d, s := total(DefaultCandidateSourceLimits()), total(SongsLikeCandidateSourceLimits())
|
||||
if s < d/2 {
|
||||
t.Errorf("songs_like pool is %d against the default %d — less than half; "+
|
||||
"this was meant to re-weight the pool, not starve it", s, d)
|
||||
}
|
||||
}
|
||||
|
||||
// The constraint that is invisible in the numbers, and that this file exists
|
||||
// to keep visible.
|
||||
//
|
||||
// `likes_overlap` and `random_fill` end in a bare `ORDER BY random()` with no
|
||||
// daily seed (recommendation.sql:118, :161). Such an arm returns a stable set
|
||||
// only while its LIMIT exceeds the eligible rows; below that it returns a
|
||||
// random SUBSET that differs between two builds on the same day, and the
|
||||
// daily-determinism promise quietly stops holding.
|
||||
//
|
||||
// This is not hypothetical — it is how this change first failed CI. Cutting
|
||||
// RandomFill to 10 broke TestBuildSystemPlaylists_DailyNonceDeterminism,
|
||||
// whose library is smaller than the default limit and whose determinism was
|
||||
// therefore an accident of the limit exceeding the library.
|
||||
//
|
||||
// Growing these arms is always safe. Only shrinking is, and the fix that
|
||||
// would make shrinking safe is a seeded ordering (#3889), not a smaller
|
||||
// number here.
|
||||
func TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms(t *testing.T) {
|
||||
d := DefaultCandidateSourceLimits()
|
||||
s := SongsLikeCandidateSourceLimits()
|
||||
|
||||
for _, tc := range []struct {
|
||||
arm string
|
||||
songsLike, dflt int
|
||||
}{
|
||||
{"RandomFill", s.RandomFill, d.RandomFill},
|
||||
{"LikesOverlap", s.LikesOverlap, d.LikesOverlap},
|
||||
} {
|
||||
if tc.songsLike < tc.dflt {
|
||||
t.Errorf("%s cut from %d to %d. That arm is ordered by unseeded random(), "+
|
||||
"so a smaller limit makes pool membership vary between same-day "+
|
||||
"rebuilds — it breaks daily determinism rather than merely narrowing "+
|
||||
"the mix. Fix the ordering (#3889) before trimming this.",
|
||||
tc.arm, tc.dflt, tc.songsLike)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ const (
|
||||
// never be read as taste signal (#2374) — filing it under taste would put
|
||||
// it one careless join from the leak that design forbids.
|
||||
ScopeDiscover = "discover"
|
||||
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared
|
||||
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug:
|
||||
// the two surfaces want opposite things. For-You answers "what will they
|
||||
// enjoy today" and is supposed to roam; Songs-like answers "what sounds
|
||||
// like THIS" and is the tightest surface in the product. One set of
|
||||
// weights cannot serve both, and the broad answer was winning.
|
||||
ScopeSongsLike = "songs_like"
|
||||
)
|
||||
|
||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||
@@ -92,6 +99,76 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||
}
|
||||
}
|
||||
|
||||
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults
|
||||
// (#3881). The whole point is that SIMILARITY DOMINATES; every other
|
||||
// profile balances it against taste and engagement, and this one must not.
|
||||
//
|
||||
// The failure being corrected, arithmetic from the daily_mix profile that
|
||||
// this surface used to share:
|
||||
//
|
||||
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
|
||||
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
|
||||
//
|
||||
// Liking something outranked sounding like the seed, because LikeBoost (2.0)
|
||||
// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and
|
||||
// seed-independent) matched it outright.
|
||||
//
|
||||
// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are
|
||||
// retuned: the similarity term's range must exceed the combined range of
|
||||
// every seed-INDEPENDENT differentiator, so that a closer match cannot be
|
||||
// beaten on the strength of likes, freshness and taste alone.
|
||||
//
|
||||
// seed-independent spread = LikeBoost 0.5 + Recency 0.25
|
||||
// + Taste 0.25 + ContextTime 0.5
|
||||
// + jitter 0.05 = 1.55
|
||||
// similarity spread = 0 → 4.0
|
||||
//
|
||||
// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of
|
||||
// everything else, while tracks within that band still get ordered by what
|
||||
// the user likes and has not heard lately. Tight, not deaf.
|
||||
//
|
||||
// BaseWeight stays 1.0: it is identical for every candidate and so
|
||||
// differentiates nothing — it sets the floor, not the shape. SkipPenalty
|
||||
// stays 2.0 because a track the user skips is still unwanted no matter how
|
||||
// similar it is.
|
||||
//
|
||||
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the
|
||||
// admin tuning card and good values get baked back here. They are a
|
||||
// defensible starting point rather than a measured optimum: the per-arm fill
|
||||
// rates and the real sim_score distribution are still unknown (#3879), and
|
||||
// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight
|
||||
// amplifies. Expect to move these once that lands.
|
||||
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
|
||||
return recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0, // same for all candidates; differentiates nothing
|
||||
LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override
|
||||
RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right
|
||||
SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted
|
||||
JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 4.0, // was 1.5 — dominant, by design
|
||||
TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard
|
||||
ContextTimeWeight: 0.5, // was 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// shippedWeightsFor returns the shipped defaults for a weight-profile scope,
|
||||
// or ok=false if the scope is not a weight profile. Single source for the
|
||||
// three call sites (seed, update-validation, reset) so adding a fourth
|
||||
// profile cannot be half-wired — which is how a scope ends up seedable but
|
||||
// not resettable.
|
||||
func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
|
||||
switch scope {
|
||||
case ScopeRadio:
|
||||
return ShippedRadioWeights(), true
|
||||
case ScopeDailyMix:
|
||||
return ShippedDailyMixWeights(), true
|
||||
case ScopeSongsLike:
|
||||
return ShippedSongsLikeWeights(), true
|
||||
}
|
||||
return recommendation.ScoringWeights{}, false
|
||||
}
|
||||
|
||||
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||||
type DiscoverTuning struct {
|
||||
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||||
@@ -165,8 +242,9 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service
|
||||
func (s *Service) reconcile(ctx context.Context) error {
|
||||
q := dbq.New(s.pool)
|
||||
for profile, w := range map[string]recommendation.ScoringWeights{
|
||||
ScopeRadio: ShippedRadioWeights(),
|
||||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||
ScopeRadio: ShippedRadioWeights(),
|
||||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||
ScopeSongsLike: ShippedSongsLikeWeights(),
|
||||
} {
|
||||
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||||
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||||
@@ -234,6 +312,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
// reads Weights(ScopeRadio) per request.
|
||||
func (s *Service) push() {
|
||||
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||||
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
|
||||
playlists.SetTasteConfig(s.TasteConfig())
|
||||
}
|
||||
|
||||
@@ -297,7 +376,7 @@ type fieldChange struct {
|
||||
// Unknown fields and out-of-range values reject the whole patch. A
|
||||
// no-op patch (all values equal to current) writes no audit row.
|
||||
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||||
if profile != ScopeRadio && profile != ScopeDailyMix {
|
||||
if _, ok := shippedWeightsFor(profile); !ok {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||||
}
|
||||
current := s.Weights(profile)
|
||||
@@ -340,17 +419,18 @@ func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64)
|
||||
// Reset restores a scope to its shipped defaults, with one audit row
|
||||
// carrying the full diff. A scope already at defaults is a no-op.
|
||||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||
switch scope {
|
||||
case ScopeRadio, ScopeDailyMix:
|
||||
shipped := ShippedRadioWeights()
|
||||
if scope == ScopeDailyMix {
|
||||
shipped = ShippedDailyMixWeights()
|
||||
}
|
||||
if shipped, ok := shippedWeightsFor(scope); ok {
|
||||
// Every weight profile resets the same way; the per-scope defaults
|
||||
// come from one place so a new profile cannot be seedable but not
|
||||
// resettable. This was an if/else over two hard-coded scopes until
|
||||
// songs_like made it three.
|
||||
changes := diffWeights(s.Weights(scope), shipped)
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||||
}
|
||||
switch scope {
|
||||
case ScopeTaste:
|
||||
shipped := ShippedTasteTuning()
|
||||
changes := diffTaste(s.Taste(), shipped)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package recsettings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// The bug, reproduced as a ranking: a track that sounds nothing like the seed
|
||||
// but which the user liked and has not played lately used to OUTRANK a perfect
|
||||
// similarity match. Operator, 2026-09-10: "I was getting a seeming wide variety
|
||||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||||
//
|
||||
// This is the whole point of the songs_like profile, so it is asserted as
|
||||
// BEHAVIOUR — two candidates, which one wins — rather than by checking the
|
||||
// weight numbers. Numbers get retuned; this property must survive that.
|
||||
//
|
||||
// It also pins the contrast: daily_mix is EXPECTED to fail this. If both
|
||||
// profiles started ranking the same way, the split would have quietly become
|
||||
// pointless and nothing else would notice.
|
||||
func TestSongsLikeWeights_SimilarityBeatsAnUnrelatedLikedTrack(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
stale := now.Add(-365 * 24 * time.Hour)
|
||||
|
||||
// A perfect similarity match the user has never liked and played recently.
|
||||
// Everything except similarity is working against it.
|
||||
perfectMatch := recommendation.ScoringInputs{
|
||||
SimilarityScore: 1.0,
|
||||
IsGeneralLiked: false,
|
||||
LastPlayedAt: &now,
|
||||
}
|
||||
// Nothing to do with the seed, but liked and long unplayed — every
|
||||
// seed-independent term in its favour.
|
||||
unrelatedFavourite := recommendation.ScoringInputs{
|
||||
SimilarityScore: 0.0,
|
||||
IsGeneralLiked: true,
|
||||
LastPlayedAt: &stale,
|
||||
TasteMatchScore: 1.0,
|
||||
}
|
||||
|
||||
// Jitter fixed at its midpoint so the comparison is about the weights.
|
||||
noJitter := func() float64 { return 0.5 }
|
||||
|
||||
songsLike := ShippedSongsLikeWeights()
|
||||
matchScore := recommendation.Score(perfectMatch, songsLike, now, noJitter)
|
||||
favScore := recommendation.Score(unrelatedFavourite, songsLike, now, noJitter)
|
||||
if matchScore <= favScore {
|
||||
t.Errorf("songs_like ranks an unrelated liked track (%.3f) at or above a "+
|
||||
"perfect similarity match (%.3f) — the mix will wander", favScore, matchScore)
|
||||
}
|
||||
|
||||
// The contrast that makes the split worth having. If this ever passes,
|
||||
// daily_mix has been tightened into songs_like and one of them is redundant.
|
||||
daily := ShippedDailyMixWeights()
|
||||
dMatch := recommendation.Score(perfectMatch, daily, now, noJitter)
|
||||
dFav := recommendation.Score(unrelatedFavourite, daily, now, noJitter)
|
||||
if dMatch > dFav {
|
||||
t.Errorf("daily_mix now also puts similarity first (%.3f vs %.3f); the two "+
|
||||
"profiles no longer differ, so songs_like is buying nothing", dMatch, dFav)
|
||||
}
|
||||
}
|
||||
|
||||
// The property the songs_like numbers encode, stated independently of them:
|
||||
// the similarity term's range must exceed the combined range of every
|
||||
// seed-INDEPENDENT differentiator, so a closer match cannot be beaten on
|
||||
// likes, freshness and taste alone.
|
||||
//
|
||||
// BaseWeight is excluded deliberately — it is identical for every candidate
|
||||
// and so differentiates nothing. SkipPenalty is excluded because it only ever
|
||||
// pushes a candidate DOWN, and a skipped track should lose however similar.
|
||||
func TestSongsLikeWeights_SimilarityOutrangesEverySeedIndependentTerm(t *testing.T) {
|
||||
w := ShippedSongsLikeWeights()
|
||||
|
||||
// TasteMatchScore and ContextAffinityScore are in [-1,+1]; recencyDecay is
|
||||
// in [0,1]; LikeBoost is all-or-nothing.
|
||||
seedIndependent := w.LikeBoost + w.RecencyWeight + w.TasteWeight +
|
||||
w.ContextTimeWeight + w.JitterMagnitude
|
||||
|
||||
if w.SimilarityWeight <= seedIndependent {
|
||||
t.Errorf("SimilarityWeight %.2f does not outrange the seed-independent "+
|
||||
"terms (%.2f) — likes/recency/taste can outvote sounding like the seed",
|
||||
w.SimilarityWeight, seedIndependent)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope that is seedable but not resettable is the half-wired shape this
|
||||
// guards: the profile appears in the admin card, the operator turns a knob,
|
||||
// and Reset then 404s on a scope the rest of the service knows about.
|
||||
func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) {
|
||||
for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} {
|
||||
if _, ok := shippedWeightsFor(scope); !ok {
|
||||
t.Errorf("scope %q has no shipped defaults; it cannot be seeded or reset", scope)
|
||||
}
|
||||
}
|
||||
// Non-weight scopes must NOT resolve here, or Reset would treat the taste
|
||||
// singleton as a weight profile and write nonsense.
|
||||
for _, scope := range []string{ScopeTaste, ScopeDiscover, "nonsense"} {
|
||||
if _, ok := shippedWeightsFor(scope); ok {
|
||||
t.Errorf("scope %q resolved as a weight profile and is not one", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the sync the comment in playlists/system.go asks for: the pre-push
|
||||
// literal there must match the shipped defaults here, or a build that has not
|
||||
// yet been reconciled ranks differently from one that has.
|
||||
func TestShippedSongsLikeWeights_AreDominatedBySimilarity(t *testing.T) {
|
||||
w := ShippedSongsLikeWeights()
|
||||
daily := ShippedDailyMixWeights()
|
||||
|
||||
if w.SimilarityWeight <= daily.SimilarityWeight {
|
||||
t.Errorf("songs_like SimilarityWeight %.2f is not above daily_mix's %.2f",
|
||||
w.SimilarityWeight, daily.SimilarityWeight)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
songs, day float64
|
||||
}{
|
||||
{"LikeBoost", w.LikeBoost, daily.LikeBoost},
|
||||
{"TasteWeight", w.TasteWeight, daily.TasteWeight},
|
||||
{"RecencyWeight", w.RecencyWeight, daily.RecencyWeight},
|
||||
} {
|
||||
if tc.songs >= tc.day {
|
||||
t.Errorf("songs_like %s (%.2f) is not demoted below daily_mix (%.2f); "+
|
||||
"these are the seed-independent terms that made the mix wander",
|
||||
tc.name, tc.songs, tc.day)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Guards the version derivation that stamps every build.
|
||||
//
|
||||
// These assertions used to be impossible to run. The derivation lived inline
|
||||
// in release.yml, which triggers only on `main` and on tags — so a mistake in
|
||||
// it could not surface until a release was already under way, and its failure
|
||||
// mode is silence: a version nobody can compare looks exactly like being up to
|
||||
// date, and nobody reports an update they were never offered.
|
||||
//
|
||||
// Moving it to ci/version.sh made it executable, so this runs on every push
|
||||
// that touches the release machinery. That is the whole point of the file; the
|
||||
// specific assertions below matter less than the fact that they run at all.
|
||||
//
|
||||
// The tests EXECUTE the script rather than asserting on its text, so they
|
||||
// break when the behaviour changes rather than when the wording does.
|
||||
|
||||
// highestOldSchemeCode is the largest versionCode ever shipped under the
|
||||
// retired commit-count scheme (v2026.09.09 shipped 1895). Every code the new
|
||||
// scheme emits must clear it, or Android would refuse the upgrade as a
|
||||
// downgrade and the update channel would be a one-way door.
|
||||
const highestOldSchemeCode = 1895
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatalf("no go.mod above %s", dir)
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// runVersion executes ci/version.sh with both clocks pinned, so the result is
|
||||
// deterministic. Returns the parsed KEY=VALUE output.
|
||||
func runVersion(t *testing.T, commitEpoch, nowEpoch string) map[string]string {
|
||||
t.Helper()
|
||||
out, err := versionScript(t, commitEpoch, nowEpoch)
|
||||
if err != nil {
|
||||
t.Fatalf("ci/version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
parsed := map[string]string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
parsed[k] = v
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func versionScript(t *testing.T, commitEpoch, nowEpoch string) (string, error) {
|
||||
t.Helper()
|
||||
root := repoRoot(t)
|
||||
cmd := exec.Command(filepath.Join(root, "ci", "version.sh"))
|
||||
cmd.Dir = root
|
||||
cmd.Env = append(os.Environ(),
|
||||
"MINSTREL_COMMIT_EPOCH="+commitEpoch,
|
||||
"MINSTREL_NOW_EPOCH="+nowEpoch,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestVersionName_IsCommitTimeToTheMinute(t *testing.T) {
|
||||
// 2025-09-09T18:48:56Z
|
||||
got := runVersion(t, "1757443736", "1789000920")
|
||||
if want := "2025.09.09.1848"; got["name"] != want {
|
||||
t.Errorf("name = %q, want %q", got["name"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// HHMM is the segment most likely to be silently mangled, and it only bites
|
||||
// for about a tenth of the day — a build just after midnight must emit "0042",
|
||||
// never "42". A stripped leading zero shifts the segment by two orders of
|
||||
// magnitude and reverses comparisons against every other build that day.
|
||||
func TestVersionName_PadsTheMinuteSegment(t *testing.T) {
|
||||
// 2026-09-10T00:42:00Z
|
||||
got := runVersion(t, "1789000920", "1789000920")
|
||||
if want := "2026.09.10.0042"; got["name"] != want {
|
||||
t.Errorf("name = %q, want %q — leading zero lost?", got["name"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionName_DerivesFromCommitNotBuildClock(t *testing.T) {
|
||||
// Same commit, two different build clocks: the NAME must not move, or a
|
||||
// dev build and a main build of one commit would report different strings
|
||||
// and the channel field would stop being the only thing separating them.
|
||||
a := runVersion(t, "1757443736", "1789000920")
|
||||
b := runVersion(t, "1757443736", "1789500000")
|
||||
if a["name"] != b["name"] {
|
||||
t.Errorf("name moved with the build clock: %q vs %q", a["name"], b["name"])
|
||||
}
|
||||
if a["code"] == b["code"] {
|
||||
t.Errorf("code did NOT move with the build clock (%q) — it is not build-derived", a["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCode_IsMinutesSince2020AndClearsTheOldScheme(t *testing.T) {
|
||||
got := runVersion(t, "1789000920", "1789000920")
|
||||
code, err := strconv.Atoi(got["code"])
|
||||
if err != nil {
|
||||
t.Fatalf("code %q is not an integer: %v", got["code"], err)
|
||||
}
|
||||
if want := (1789000920 - 1577836800) / 60; code != want {
|
||||
t.Errorf("code = %d, want %d", code, want)
|
||||
}
|
||||
if code <= highestOldSchemeCode {
|
||||
t.Errorf("code %d does not clear the retired commit-count scheme (%d) — "+
|
||||
"Android would refuse the upgrade as a downgrade", code, highestOldSchemeCode)
|
||||
}
|
||||
if int64(code) > 2147483647 {
|
||||
t.Errorf("code %d overflows versionCode's int32 ceiling", code)
|
||||
}
|
||||
}
|
||||
|
||||
// The tag is not chosen, it is the name with a `v`. Anything else reintroduces
|
||||
// the mismatch between what a tag claims and what the artifact reports.
|
||||
func TestTag_IsTheNameWithAPrefix(t *testing.T) {
|
||||
got := runVersion(t, "1757443736", "1789000920")
|
||||
if want := "v" + got["name"]; got["tag"] != want {
|
||||
t.Errorf("tag = %q, want %q", got["tag"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A guard that cannot fail is worse than no guard. These prove the script
|
||||
// rejects the shapes it claims to reject, rather than emitting something
|
||||
// plausible and letting it ship.
|
||||
func TestVersionScript_RejectsUnusableClocks(t *testing.T) {
|
||||
for _, tc := range []struct{ name, commit, now string }{
|
||||
{"unreadable commit timestamp", "notanumber", "1789000920"},
|
||||
{"non-numeric build clock", "1789000920", "abc"},
|
||||
{"build clock before 2020", "1789000920", "1000000000"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := versionScript(t, tc.commit, tc.now)
|
||||
if err == nil {
|
||||
t.Errorf("script succeeded on %s, output: %s", tc.name, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the wiring, not the formula: if release.yml stops calling the script,
|
||||
// every assertion above keeps passing while the thing that actually ships goes
|
||||
// unguarded again. That silent decoupling is the specific regression here.
|
||||
func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
|
||||
if !strings.Contains(yaml, "ci/version.sh") {
|
||||
t.Error("release.yml no longer calls ci/version.sh — the derivation has drifted out of test coverage")
|
||||
}
|
||||
// The retired scheme, which must not come back. A presence check is safe
|
||||
// against prose; the historical note in that file names the old formula
|
||||
// only in comments, so match the executable form.
|
||||
if strings.Contains(yaml, "$(git rev-list --count") {
|
||||
t.Error("release.yml derives a commit count again — that is not monotonic across branches")
|
||||
}
|
||||
}
|
||||
|
||||
// jobBoundary matches a blank line followed by job-level (two-space)
|
||||
// indentation — the end of the last step in a job.
|
||||
var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
|
||||
|
||||
// stepBody returns one workflow step's text, from its `- name:` line to the
|
||||
// start of the next step or the next job.
|
||||
//
|
||||
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
|
||||
// for the last step in a job, and every assertion over it then passes
|
||||
// vacuously. That is the failure rule 167 names: a check that reads as
|
||||
// coverage while asserting on nothing. Cutting at a blank line followed by
|
||||
// job-level indentation handles the last-step case, which is exactly where
|
||||
// `Build and push` sits.
|
||||
func stepBody(t *testing.T, yaml, name string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(yaml, "- name: "+name)
|
||||
if i < 0 {
|
||||
t.Fatalf("no %q step in release.yml", name)
|
||||
}
|
||||
body := yaml[i:]
|
||||
end := len(body)
|
||||
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
||||
end = j
|
||||
}
|
||||
// A blank line followed by EXACTLY two spaces and then content starts a
|
||||
// new job or job-level comment. The "exactly" matters: a blank line inside
|
||||
// a `run:` block is followed by ten-space indentation and would otherwise
|
||||
// match, truncating the step mid-body — which is how this helper first
|
||||
// sliced the verify step down to its first two lines.
|
||||
if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end {
|
||||
end = loc[0]
|
||||
}
|
||||
body = body[:end]
|
||||
if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" {
|
||||
t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// releaseYAML reads the workflow once per test.
|
||||
func releaseYAML(t *testing.T) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// imageTagArms splits "Compute image tags" into its three ref branches. The
|
||||
// whole tag policy lives in that if/elif/else, so the arms are the unit worth
|
||||
// asserting on — a tag published from the wrong arm reaches the wrong
|
||||
// audience, and every such mistake still builds and still pushes a valid
|
||||
// image.
|
||||
func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
|
||||
t.Helper()
|
||||
const (
|
||||
tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then`
|
||||
devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
mainMarker = "\n else"
|
||||
endMarker = "\n fi"
|
||||
)
|
||||
iTag := strings.Index(yaml, tagMarker)
|
||||
iDev := strings.Index(yaml, devMarker)
|
||||
iMain := -1
|
||||
if iDev >= 0 {
|
||||
if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 {
|
||||
iMain = iDev + k
|
||||
}
|
||||
}
|
||||
if iTag < 0 || iDev < 0 || iMain < 0 {
|
||||
t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing")
|
||||
}
|
||||
iEnd := iMain
|
||||
if k := strings.Index(yaml[iMain:], endMarker); k >= 0 {
|
||||
iEnd = iMain + k
|
||||
} else {
|
||||
t.Fatal("no closing fi after the main arm")
|
||||
}
|
||||
return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd]
|
||||
}
|
||||
|
||||
// The worst regression this wiring can produce: a dev push that also moves
|
||||
// :latest would ship untested code to every stable operator, silently, on the
|
||||
// next pull. Nothing else in the suite would notice — the build stays green
|
||||
// and the image is valid, it is simply the wrong audience.
|
||||
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, ":latest") {
|
||||
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
|
||||
}
|
||||
// Rule 145: a rolling channel gets no commit-addressable tag.
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||
// image would still build and the sidecar would still parse — it would just
|
||||
// quietly serve stale art to the channel whose whole job is being current.
|
||||
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
|
||||
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
|
||||
// command substitution whose grep matches NOTHING fatal at the assignment —
|
||||
// pipefail reports the grep's non-zero status even though `head` succeeded —
|
||||
// so the step dies before reaching the `if` written to handle the empty case.
|
||||
//
|
||||
// This is not hypothetical. It took down the first `main` build after the
|
||||
// version rework: the newest release predated sidecar assets, its
|
||||
// `.apk.version` grep matched nothing, and the step aborted instead of
|
||||
// falling through to the name-only branch that exists precisely for it. The
|
||||
// same latent hazard sat on the other two assignments and had simply never
|
||||
// fired, because their greps always matched.
|
||||
//
|
||||
// Pins the property that makes every "degrades gracefully" branch in that
|
||||
// step reachable at all.
|
||||
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(step, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
|
||||
continue
|
||||
}
|
||||
// Only assignments from a command substitution can abort the step.
|
||||
if !strings.Contains(trimmed, `="$(`) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "|| true") {
|
||||
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
|
||||
"skipping the empty-case branch below it:\n %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rollback unit, and the reason it is worth a guard: it is invisible until
|
||||
// the moment it is needed. Nothing pulls :<sha> during normal operation, so if
|
||||
// this arm stopped minting one, every build would stay green and every image
|
||||
// would be valid — and the absence would surface only during an incident, as
|
||||
// "there is nothing to roll back to."
|
||||
//
|
||||
// Rules 145 and 147: main push → :latest + :<sha>.
|
||||
func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
|
||||
_, _, arm := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm)
|
||||
}
|
||||
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
|
||||
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
|
||||
}
|
||||
// Rule 147: :latest tracks main's tip, and a second name for the same
|
||||
// image sends readers looking for a distinction that does not exist.
|
||||
if strings.Contains(arm, "${IMAGE}:main") {
|
||||
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146).
|
||||
//
|
||||
// The specific regression: re-adding :<sha> here. The tag build rebuilds the
|
||||
// SAME SOURCE as main's build minutes earlier, differing only in which APK is
|
||||
// baked in — so a :<sha> minted here would overwrite main's immutable rollback
|
||||
// target with different contents, under the same name. That is the exact thing
|
||||
// rule 145's immutability clause exists to prevent, and it is the half with
|
||||
// the incidents behind it.
|
||||
func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// No version-numbered image tags anywhere, on any arm (rule 145, and the
|
||||
// operator's 2026-09-10 decision to drop them across every project).
|
||||
//
|
||||
// Asserted across the whole step rather than per-arm because the mistake this
|
||||
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
|
||||
// spot. `${VERSION}` still legitimately appears in the step as the build's
|
||||
// self-reported version, so the assertion has to name the image-tag form
|
||||
// specifically rather than the variable — otherwise it would fire on correct
|
||||
// code and get "fixed" by deleting the guard.
|
||||
func TestNoVersionNumberedImageTags(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
tagArm, devArm, mainArm := imageTagArms(t, yaml)
|
||||
|
||||
for _, tc := range []struct{ name, arm string }{
|
||||
{"tag", tagArm}, {"dev", devArm}, {"main", mainArm},
|
||||
} {
|
||||
for _, forbidden := range []string{
|
||||
"${IMAGE}:${VERSION}",
|
||||
"${IMAGE}:v",
|
||||
"${IMAGE}:${GITHUB_REF#refs/tags/}",
|
||||
} {
|
||||
if strings.Contains(tc.arm, forbidden) {
|
||||
t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s",
|
||||
tc.name, forbidden, tc.arm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verify job asserted the :<version> image existed. With version tags
|
||||
// gone that assertion would fail every release for a tag nothing mints — so
|
||||
// this pins that it was re-pointed rather than deleted, since deleting it is
|
||||
// the tempting way to make a failing guard go green.
|
||||
func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
|
||||
if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") {
|
||||
t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification")
|
||||
}
|
||||
step := stepBody(t, yaml, "Rollback image must exist for the tagged commit")
|
||||
if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") {
|
||||
t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step)
|
||||
}
|
||||
if strings.Contains(step, "${IMAGE}:${TAG}") {
|
||||
t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
// The server's self-reported version is now the ONLY thing that identifies a
|
||||
// build, so a lane that stamps a channel word instead of a version silently
|
||||
// removes that ability. It used to stamp the literal "main"/"dev".
|
||||
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
|
||||
step := stepBody(t, yaml, "Build and push")
|
||||
for _, want := range []string{
|
||||
"MINSTREL_VERSION=",
|
||||
"MINSTREL_CHANNEL=",
|
||||
} {
|
||||
if !strings.Contains(step, want) {
|
||||
t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step)
|
||||
}
|
||||
}
|
||||
|
||||
// The version must come from the shared derivation, not from the ref.
|
||||
// Reading it off GITHUB_REF is what produced "main" and "dev" as version
|
||||
// strings, which is the regression this pins.
|
||||
_, _, mainArm := imageTagArms(t, yaml)
|
||||
if strings.Contains(mainArm, `version=main`) {
|
||||
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
||||
}
|
||||
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
|
||||
}
|
||||
}
|
||||
|
||||
// gitRepo builds a throwaway repo and returns its path. Commit timestamps are
|
||||
// pinned so the derivation is deterministic.
|
||||
func gitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("-c", "init.defaultBranch=main", "init", "-q")
|
||||
run("config", "user.email", "t@example.invalid")
|
||||
run("config", "user.name", "t")
|
||||
return dir
|
||||
}
|
||||
|
||||
// commitFile writes path and commits it with a pinned committer timestamp.
|
||||
func commitFile(t *testing.T, dir, path, epoch string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Content must DIFFER from any earlier write to the same path, or git
|
||||
// records nothing and the commit silently covers fewer files than the
|
||||
// test believes. That is not hypothetical: it made the
|
||||
// source-alongside-test case pass vacuously.
|
||||
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", path}} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
||||
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// versionIn runs ci/version.sh inside dir, reading real git rather than the
|
||||
// pinned-clock override, so the PATHSPEC is what is under test.
|
||||
func versionIn(t *testing.T, dir string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"MINSTREL_NOW_EPOCH=1789000920",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// The version names what SHIPPED, so a commit that changes nothing shippable
|
||||
// must not move it.
|
||||
//
|
||||
// The failure this prevents is not the cosmetic one. The derivation is a
|
||||
// denylist precisely so that new content counts by default: the direction that
|
||||
// matters is a changed artifact keeping its OLD version, silently, on a green
|
||||
// run. This test pins the cheap half of that (CI-only commits are inert) and,
|
||||
// in the same breath, that a source commit still moves it — because a pathspec
|
||||
// typo that excluded everything would satisfy the first assertion alone.
|
||||
func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736" // 2025-09-09T18:48:56Z
|
||||
ciOnly = "1789000920" // 2026-09-10T00:42:00Z, later
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFile(t, dir, ".gitea/workflows/release.yml", ciOnly)
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a CI-only commit moved the version — the pathspec is not excluding it\n%s", out)
|
||||
}
|
||||
|
||||
// ...and the pathspec must not be so broad it excludes everything.
|
||||
commitFile(t, dir, "internal/server/other.go", ciOnly)
|
||||
out, err = versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("a source commit did NOT move the version — the pathspec excludes too much, which is the silent-lie direction\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// android/ is deliberately NOT excluded, and that is the subtle half of the
|
||||
// list. It ships in no server image — but it is the APK's entire source, and
|
||||
// ONE script derives the version for both artifacts. Excluding it would stop
|
||||
// an Android-only commit from moving the APK's own version, which is exactly
|
||||
// the silent downgrade the versioning rework exists to prevent.
|
||||
func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", "1757443736")
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", "1789000920")
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// No shipped commit in range means a shallow clone, and the script must refuse
|
||||
// rather than emit something plausible. A wrong version builds, signs and
|
||||
// publishes perfectly happily; it surfaces later as an update channel that has
|
||||
// quietly stopped offering anything.
|
||||
func TestVersionScript_RefusesWhenNothingShippedIsInRange(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "ci/version.sh", "1789000920")
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err == nil {
|
||||
t.Fatalf("script succeeded with no shipped commit in range; it should refuse\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "name=") {
|
||||
t.Errorf("script emitted a version name while refusing — that value could still be consumed\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// commitFiles is commitFile for more than one path in a single commit.
|
||||
func commitFiles(t *testing.T, dir, epoch string, paths ...string) {
|
||||
t.Helper()
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", strings.Join(paths, " ")}} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
||||
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitOutput runs a git command in dir and returns its combined output.
|
||||
func gitOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Tests do not ship, so a test-only commit must not re-version an artifact.
|
||||
//
|
||||
// `go build` drops *_test.go outright and the Vite build never imports a
|
||||
// .test.ts, so neither reaches an image or an APK. This repo has no tests/
|
||||
// tree — Go tests sit inline beside the code — so the exclusions are globs,
|
||||
// and a glob is easy to get subtly wrong in a way that still looks right.
|
||||
func TestVersionName_TestsDoNotReVersionTheArtifact(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736" // 2025.09.09.1848
|
||||
later = "1789000920" // 2026.09.10.0042
|
||||
)
|
||||
for _, tc := range []struct{ name, path string }{
|
||||
{"go test beside its source", "internal/server/thing_test.go"},
|
||||
{"web unit test", "web/src/lib/api/admin.test.ts"},
|
||||
{"web script test", "web/scripts/tokens-to-css.test.js"},
|
||||
{"android JVM unit test", "android/app/src/test/java/A.kt"},
|
||||
{"vitest harness config", "web/vitest.config.ts"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFile(t, dir, tc.path, later)
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a commit touching only %s moved the version; tests do not ship\n%s", tc.path, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The direction that actually costs something, and the reason the exclusions
|
||||
// above are globs over FILES rather than over their directories.
|
||||
//
|
||||
// `':!internal'` would satisfy every assertion in the test above while
|
||||
// silently excluding the entire server. This pins the opposite: a commit that
|
||||
// changes a test AND the source under it must still move the version, because
|
||||
// the source path matches on its own. Without this, a too-broad exclusion
|
||||
// reads as a passing test suite and ships an artifact under a stale version.
|
||||
func TestVersionName_ATestAlongsideItsSourceStillCounts(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736"
|
||||
later = "1789000920"
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFiles(t, dir, later,
|
||||
"internal/server/thing.go",
|
||||
"internal/server/thing_test.go",
|
||||
)
|
||||
|
||||
// The commit must actually contain BOTH paths. Rewriting a file with
|
||||
// identical bytes records nothing, and this assertion would then be
|
||||
// checking a test-only commit while appearing to check a mixed one.
|
||||
touched := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
|
||||
for _, want := range []string{"internal/server/thing.go", "internal/server/thing_test.go"} {
|
||||
if !strings.Contains(touched, want) {
|
||||
t.Fatalf("fixture is wrong: HEAD does not contain %s\n%s", want, touched)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("a source change accompanied by a test change did NOT move the version — "+
|
||||
"the exclusions are matching directories rather than test files\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": ServerVersion,
|
||||
"channel": ServerChannel,
|
||||
"min_client_version": MinClientVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,27 @@ package server
|
||||
// older clients see version_too_old at /healthz and refuse to operate.
|
||||
const MinClientVersion = "0.1.0"
|
||||
|
||||
// ServerVersion is the deployed server image's version tag. Defaults to
|
||||
// "dev" for local builds; overridden at link time via:
|
||||
// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
|
||||
// from the commit it was built from by ci/version.sh. Defaults to "dev" for
|
||||
// local builds; overridden at link time via:
|
||||
//
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
|
||||
//
|
||||
// release.yml passes the git tag through MINSTREL_VERSION build-arg →
|
||||
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
|
||||
// image their container is running without exec'ing into it.
|
||||
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
|
||||
// ldflag. Surfaced at /healthz so operators can verify which image their
|
||||
// container is running without exec'ing into it.
|
||||
//
|
||||
// This carried the literal strings "main" and "dev" until 2026-09-10, which
|
||||
// made every image on a channel report the same thing forever. It stopped
|
||||
// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
|
||||
// 145): this is now the ONLY thing that says which build is running.
|
||||
var ServerVersion = "dev"
|
||||
|
||||
// ServerChannel is which line this build came off — "stable" or "dev", or
|
||||
// "local" for a plain `docker build`.
|
||||
//
|
||||
// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
|
||||
// same commit built on both lanes reports the same version and differs only
|
||||
// here; folding the two together is what makes a version string stop being
|
||||
// comparable.
|
||||
var ServerChannel = "local"
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the Minstrel logo and emit every brand asset that derives from it.
|
||||
|
||||
Run by hand after the source artwork changes, never in CI — it needs potrace
|
||||
and Pillow, which the build images don't carry, and its outputs are committed
|
||||
(the app must render itself with no outbound network).
|
||||
|
||||
python3 tools/gen-brand-assets.py
|
||||
|
||||
Why a generator at all: the mark ships in four places that cannot share one
|
||||
file, because each needs a different colour mechanism — currentColor in the
|
||||
component, literal fills in mark.svg, a prefers-color-scheme swap in
|
||||
favicon.svg, and flattened pixels in the rasters. Hand-copying ~20KB of path
|
||||
data four ways is how a silhouette change lands in three of them and not the
|
||||
fourth. The paths have one source here instead.
|
||||
|
||||
The trace pipeline, and why each step is what it is:
|
||||
|
||||
* Components are separated first (connected-component labelling) so the hat
|
||||
and the note arc can take different fills. Grouping is by component id
|
||||
against the source raster, which is stable as long as SOURCE doesn't move.
|
||||
* Tracing runs on the ORIGINAL antialiased greyscale, upsampled and blurred
|
||||
— not on the binary mask. Tracing the mask supersampled hits 100% IoU by
|
||||
reproducing the pixel staircase exactly: a perfect score for jagged art
|
||||
and a 120KB path. Smoothing first finds a sub-pixel boundary instead,
|
||||
which is 99.74% IoU at 20KB.
|
||||
* potrace reads PBM, where bit 1 is BLACK, so the ink mask is inverted on
|
||||
the way in. Getting this backwards traces the background and still
|
||||
produces a plausible-looking SVG.
|
||||
"""
|
||||
import re, shutil, subprocess, sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / "docs/brand/minstrel-logo-source.png"
|
||||
|
||||
PARCHMENT = "#E8E4D8"
|
||||
ACCENT = "#4A6B5C" # --fs-accent; the one row Minstrel overrides
|
||||
OBSIDIAN = "#14171A" # plate for rasters — see the contrast note in README
|
||||
|
||||
# Component ids from the source raster: the hat body, brim, feather, band and
|
||||
# jewel take the text colour; the noteheads and the arc swashes take the accent.
|
||||
HAT = [396, 58, 251, 497, 442]
|
||||
NOTES = [31, 207, 30, 247, 317, 135, 78, 161, 186, 107, 215]
|
||||
FRAME = [1] # the source's rounded-rect border — deliberately NOT traced: an
|
||||
# adaptive icon gets masked to the launcher's own shape, so a
|
||||
# baked border would be clipped raggedly, and at header size it
|
||||
# is noise around a mark that is already busy.
|
||||
|
||||
SUPERSAMPLE, BLUR = 4, 1.5
|
||||
POTRACE = ["-s", "--flat", "-a", "1.334", "-O", "0.2", "-u", "4", "-t", "20"]
|
||||
|
||||
|
||||
def components(gray):
|
||||
"""Label 8-connected runs of ink. Two-pass union-find; no scipy here."""
|
||||
ink = gray > 128
|
||||
H, W = ink.shape
|
||||
parent, labels, nxt = {}, np.zeros((H, W), np.int32), 1
|
||||
|
||||
def find(x):
|
||||
r = x
|
||||
while parent[r] != r:
|
||||
r = parent[r]
|
||||
while parent[x] != r:
|
||||
parent[x], x = r, parent[x]
|
||||
return r
|
||||
|
||||
for y in range(H):
|
||||
if not ink[y].any():
|
||||
continue
|
||||
prev = labels[y - 1] if y else None
|
||||
for x in np.nonzero(ink[y])[0]:
|
||||
nb = [labels[y, x - 1]] if x and labels[y, x - 1] else []
|
||||
if y:
|
||||
nb += [prev[xx] for xx in (x - 1, x, x + 1) if 0 <= xx < W and prev[xx]]
|
||||
if nb:
|
||||
m = min(nb)
|
||||
labels[y, x] = m
|
||||
for n in nb:
|
||||
rm, rn = find(m), find(n)
|
||||
if rm != rn:
|
||||
parent[max(rm, rn)] = min(rm, rn)
|
||||
else:
|
||||
parent[nxt] = nxt
|
||||
labels[y, x] = nxt
|
||||
nxt += 1
|
||||
flat = np.array([0] + [find(i) for i in range(1, nxt)], np.int32)
|
||||
return flat[labels]
|
||||
|
||||
|
||||
def trace(labels, gray, ids, tmp):
|
||||
keep = np.isin(labels, ids)
|
||||
# Dilate the group mask so the component's own antialiased fringe survives;
|
||||
# thresholding a hard-cut mask would shave a half-pixel off every edge.
|
||||
grown = Image.fromarray((keep * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(5))
|
||||
img = Image.fromarray(np.where(np.asarray(grown), gray, 0.0).astype(np.uint8))
|
||||
H, W = labels.shape
|
||||
img = img.resize((W * SUPERSAMPLE, H * SUPERSAMPLE), Image.BICUBIC)
|
||||
img = img.filter(ImageFilter.GaussianBlur(BLUR))
|
||||
img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / "g.pbm")
|
||||
subprocess.run(["potrace", *POTRACE, str(tmp / "g.pbm"), "-o", str(tmp / "g.svg")], check=True)
|
||||
s = (tmp / "g.svg").read_text()
|
||||
return (" ".join(re.findall(r'<path d="([^"]+)"', s)),
|
||||
re.search(r'<g transform="([^"]+)"', s).group(1))
|
||||
|
||||
|
||||
def wrap(d, width=78, indent=" " * 6):
|
||||
"""Re-wrap path data so diffs stay reviewable instead of one endless line."""
|
||||
out, line = [], ""
|
||||
for tok in d.split():
|
||||
if len(line) + len(tok) + 1 > width:
|
||||
out.append(line)
|
||||
line = tok
|
||||
else:
|
||||
line = f"{line} {tok}".strip()
|
||||
out.append(line)
|
||||
return ("\n" + indent).join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The reduced mark, for small sizes.
|
||||
#
|
||||
# NOT a simplification of the traced art — that was tried and does not work.
|
||||
# The source composition is dominated by a long diagonal plume, so every
|
||||
# mechanical reduction of it (hole-filling, morphological smoothing, dropping
|
||||
# components) collapses at 16px into a diagonal smear that reads as no object
|
||||
# at all. What reads at that size is a strong horizontal brim under a leaning
|
||||
# crown, which has to be DRAWN rather than derived.
|
||||
#
|
||||
# So this is hand-authored geometry, tuned against the real thing: the crown
|
||||
# peaks left of centre with a long right flank, the brim rides up at the right
|
||||
# tip, and a band slit keeps crown and brim from fusing into one lump. It is a
|
||||
# family member of the full mark, not a copy of it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RN = 640 # design-space square for the reduced mark
|
||||
|
||||
|
||||
def _bez(pts, n=60):
|
||||
"""Sample a chain of cubic beziers given as [P0, C1,C2,P1, C1,C2,P2, ...]."""
|
||||
out = [pts[0]]
|
||||
for i in range(1, len(pts), 3):
|
||||
p0, (c1, c2, p1) = out[-1], pts[i:i + 3]
|
||||
for t in np.linspace(0, 1, n)[1:]:
|
||||
u = 1 - t
|
||||
out.append((u**3 * p0[0] + 3*u*u*t * c1[0] + 3*u*t*t * c2[0] + t**3 * p1[0],
|
||||
u**3 * p0[1] + 3*u*u*t * c1[1] + 3*u*t*t * c2[1] + t**3 * p1[1]))
|
||||
return out
|
||||
|
||||
|
||||
def _reduced(plume):
|
||||
"""The reduced hat. plume=False gives crown+brim only; the plume is taken
|
||||
as the difference between the two so it can be filled separately."""
|
||||
im = Image.new("L", (RN, RN), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
by = 336
|
||||
# Brim: the single strongest horizontal in the mark, and the shape that
|
||||
# says "hat" at 16px, so it stays thick and unbroken end to end.
|
||||
rt = by - 74
|
||||
d.polygon(_bez([(38, by + 10),
|
||||
(150, by - 58), (430, by - 72), (590, rt),
|
||||
(614, rt + 10), (610, rt + 40), (586, rt + 54),
|
||||
(450, by + 56), (150, by + 58), (38, by + 30),
|
||||
(22, by + 26), (22, by + 16), (38, by + 10)]), fill=255)
|
||||
# Crown: peak pushed left of centre with a long right flank — the lean is
|
||||
# what keeps this recognisably the same hat as the traced one.
|
||||
px, top = 260, by - 256
|
||||
d.polygon(_bez([(190, by + 16),
|
||||
(182, by - 96), (px - 92, top + 66), (px - 40, top + 10),
|
||||
(px - 6, top - 14), (px + 44, top + 10), (px + 62, top + 56),
|
||||
(px + 108, top + 150), (422, by - 120), (440, by - 34),
|
||||
(448, by - 4), (424, by + 18), (190, by + 16)]), fill=255)
|
||||
if plume:
|
||||
# A pointed leaf, deliberately short: a long thin plume is exactly what
|
||||
# turns the whole mark into a diagonal bar at small sizes.
|
||||
d.polygon(_bez([(404, by - 158),
|
||||
(466, by - 240), (546, by - 296), (586, by - 310),
|
||||
(580, by - 270), (532, by - 196), (458, by - 132)]), fill=255)
|
||||
a = np.asarray(im) > 128
|
||||
# Band slit: across the crown base only. Never across the brim — breaking
|
||||
# the brim costs more legibility than the band gap buys.
|
||||
g = Image.new("L", (RN, RN), 0)
|
||||
ImageDraw.Draw(g).polygon(_bez([(192, by - 30),
|
||||
(256, by - 50), (376, by - 54), (438, by - 58),
|
||||
(446, by - 32), (300, by - 26), (192, by - 6)]), fill=255)
|
||||
return a & ~(np.asarray(g) > 128)
|
||||
|
||||
|
||||
def trace_reduced(tmp):
|
||||
"""Trace the reduced hat and plume as two fills, on one shared viewBox."""
|
||||
full, hat_only = _reduced(True), _reduced(False)
|
||||
plume_only = full & ~hat_only
|
||||
ys, xs = np.nonzero(full)
|
||||
pad = 8
|
||||
box = (xs.min() - pad, ys.min() - pad, xs.max() + pad, ys.max() + pad)
|
||||
|
||||
def one(mask, name):
|
||||
# Ink BRIGHT here, matching what trace() feeds potrace: the point()
|
||||
# below is what inverts to PBM's ink-is-black. Passing an already-dark
|
||||
# mask double-inverts and silently traces the background instead.
|
||||
img = Image.fromarray((mask * 255).astype(np.uint8))
|
||||
img = img.resize((RN * SUPERSAMPLE, RN * SUPERSAMPLE), Image.BICUBIC)
|
||||
img = img.filter(ImageFilter.GaussianBlur(BLUR))
|
||||
img.point(lambda p: 0 if p > 128 else 255).convert("1", dither=Image.NONE).save(tmp / f"{name}.pbm")
|
||||
subprocess.run(["potrace", *POTRACE, str(tmp / f"{name}.pbm"), "-o", str(tmp / f"{name}.svg")], check=True)
|
||||
s = (tmp / f"{name}.svg").read_text()
|
||||
return (" ".join(re.findall(r'<path d="([^"]+)"', s)),
|
||||
re.search(r'<g transform="([^"]+)"', s).group(1))
|
||||
|
||||
hat_d, tr = one(hat_only, "rhat")
|
||||
plume_d, _ = one(plume_only, "rplume")
|
||||
S = SUPERSAMPLE
|
||||
vb = f"{box[0]*S} {box[1]*S} {(box[2]-box[0])*S} {(box[3]-box[1])*S}"
|
||||
return hat_d, plume_d, tr, vb
|
||||
|
||||
|
||||
def svg_doc(vb, tr, hat_fill, notes_fill, hat_d, notes_d, style="", head=""):
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">\n'
|
||||
f'{head}{style}'
|
||||
f' <g transform="{tr}" fill-rule="evenodd">\n'
|
||||
f' <path fill="{hat_fill}" d="{wrap(hat_d)}"/>\n'
|
||||
f' <path fill="{notes_fill}" d="{wrap(notes_d)}"/>\n'
|
||||
f' </g>\n</svg>\n')
|
||||
|
||||
|
||||
def rasterize(svg_path, out, px, plate=None, radius_frac=0.0, art_frac=1.0):
|
||||
"""Render the mark to a PNG, optionally on a rounded plate.
|
||||
|
||||
Rasters carry their own background because a PNG cannot answer to a colour
|
||||
scheme and iOS composites the touch icon onto white regardless. Obsidian
|
||||
rather than the raised iron: the accent clears the 3:1 graphics floor
|
||||
against obsidian (3.04:1) and fails against iron (2.70:1). Lightening the
|
||||
plate makes this worse, not better, because the accent is a dark colour.
|
||||
"""
|
||||
art = round(px * art_frac)
|
||||
subprocess.run(["rsvg-convert", "-w", str(art), "-o", str(out), str(svg_path)], check=True)
|
||||
mark = Image.open(out).convert("RGBA")
|
||||
canvas = Image.new("RGBA", (px, px), (0, 0, 0, 0))
|
||||
if plate:
|
||||
r = round(px * radius_frac)
|
||||
layer = Image.new("RGBA", (px, px), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(layer).rounded_rectangle([0, 0, px - 1, px - 1], radius=r, fill=plate)
|
||||
canvas.alpha_composite(layer)
|
||||
canvas.alpha_composite(mark, ((px - mark.width) // 2, (px - mark.height) // 2))
|
||||
canvas.save(out)
|
||||
|
||||
|
||||
def main():
|
||||
tmp = ROOT / ".brand-tmp"
|
||||
tmp.mkdir(exist_ok=True)
|
||||
if not SOURCE.exists():
|
||||
sys.exit(f"source artwork missing: {SOURCE}")
|
||||
|
||||
a = np.asarray(Image.open(SOURCE).convert("RGB")).astype(np.float32)
|
||||
gray = 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2]
|
||||
labels = components(gray)
|
||||
|
||||
hat_d, tr = trace(labels, gray, HAT, tmp)
|
||||
notes_d, _ = trace(labels, gray, NOTES, tmp)
|
||||
|
||||
# Tight viewBox around the traced art (frame excluded), in potrace's user
|
||||
# space, which is source pixels x SUPERSAMPLE.
|
||||
ys, xs = np.nonzero(np.isin(labels, HAT + NOTES))
|
||||
pad = 6
|
||||
x0, x1 = xs.min() - pad, xs.max() + pad
|
||||
y0, y1 = ys.min() - pad, ys.max() + pad
|
||||
S = SUPERSAMPLE
|
||||
vb = f"{x0*S} {y0*S} {(x1-x0)*S} {(y1-y0)*S}"
|
||||
w, h = (x1 - x0) * S, (y1 - y0) * S
|
||||
|
||||
(ROOT / "web/static/brand/mark.svg").write_text(svg_doc(
|
||||
vb, tr, PARCHMENT, ACCENT, hat_d, notes_d,
|
||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
||||
" Literal colours, for any consumer that cannot inline the SVG and\n"
|
||||
" therefore cannot supply a currentColor. -->\n"))
|
||||
|
||||
# The reduced mark, for anywhere the full art cannot resolve.
|
||||
rhat_d, rplume_d, rtr, rvb = trace_reduced(tmp)
|
||||
|
||||
(ROOT / "web/static/brand/mark-small.svg").write_text(svg_doc(
|
||||
rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d,
|
||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
||||
" The reduced mark, literal colours. For use at or below ~24px,\n"
|
||||
" where the full art collapses into a diagonal smear. -->\n"))
|
||||
|
||||
# Favicon uses the REDUCED mark: a browser tab renders it at 16px, and the
|
||||
# full art is unreadable there. This is the one consumer whose size is not
|
||||
# ours to choose, so it takes the form drawn for that size.
|
||||
(ROOT / "web/static/brand/favicon.svg").write_text(svg_doc(
|
||||
rvb, rtr, "currentColor", ACCENT, rhat_d, rplume_d,
|
||||
head=" <!-- Generated by tools/gen-brand-assets.py — do not edit by hand.\n"
|
||||
" The REDUCED mark, because a tab renders this at 16px.\n"
|
||||
" The hat flips with the viewer's scheme because a favicon sits on\n"
|
||||
" browser chrome we don't control: parchment would vanish on a light\n"
|
||||
" tab strip, obsidian on a dark one. The plume holds the accent in\n"
|
||||
" both — teal reads against either (3.04:1 on obsidian, 5.43:1 on\n"
|
||||
" the light ground). -->\n",
|
||||
style=" <style>\n"
|
||||
f" svg {{ color: {PARCHMENT}; }}\n"
|
||||
f" @media (prefers-color-scheme: light) {{ svg {{ color: {OBSIDIAN}; }} }}\n"
|
||||
" </style>\n"))
|
||||
|
||||
plated = ROOT / ".brand-tmp/plated.svg"
|
||||
plated.write_text(svg_doc(vb, tr, PARCHMENT, ACCENT, hat_d, notes_d))
|
||||
|
||||
# Web rasters: plate radius and art scale measured off the icons these
|
||||
# replace, so the new mark sits where the old one did.
|
||||
for out, px in [("web/static/brand/icon-512.png", 512),
|
||||
("web/static/apple-touch-icon.png", 180)]:
|
||||
rasterize(plated, ROOT / out, px, plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88)
|
||||
|
||||
# 32px fallback favicon: reduced art, same reasoning as favicon.svg.
|
||||
plated_small = ROOT / ".brand-tmp/plated-small.svg"
|
||||
plated_small.write_text(svg_doc(rvb, rtr, PARCHMENT, ACCENT, rhat_d, rplume_d))
|
||||
rasterize(plated_small, ROOT / "web/static/favicon.png", 32,
|
||||
plate=OBSIDIAN, radius_frac=0.20, art_frac=0.90)
|
||||
|
||||
# Adaptive icon foreground: transparent, art inside the 66/108dp safe zone
|
||||
# so no launcher mask can clip it. The plate comes from the XML instead.
|
||||
res = ROOT / "android/app/src/main/res"
|
||||
for d, fg, legacy in [("mdpi", 108, 48), ("hdpi", 162, 72), ("xhdpi", 216, 96),
|
||||
("xxhdpi", 324, 144), ("xxxhdpi", 432, 192)]:
|
||||
rasterize(plated, res / f"mipmap-{d}/ic_launcher_foreground.png", fg, art_frac=0.58)
|
||||
rasterize(plated, res / f"mipmap-{d}/ic_launcher.png", legacy,
|
||||
plate=OBSIDIAN, radius_frac=0.20, art_frac=0.88)
|
||||
|
||||
(ROOT / "web/src/lib/components/MinstrelMark.svelte").write_text(
|
||||
COMPONENT.format(vb=vb, tr=tr, w=w, h=h,
|
||||
hat=wrap(hat_d, indent=" " * 6), notes=wrap(notes_d, indent=" " * 6),
|
||||
accent=ACCENT))
|
||||
|
||||
print(f"viewBox {vb} aspect {w/h:.4f} hat {len(hat_d)/1024:.1f}KB notes {len(notes_d)/1024:.1f}KB")
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
|
||||
COMPONENT = '''<script lang="ts">
|
||||
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
|
||||
//
|
||||
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
|
||||
// The same silhouette ships in web/static/brand/{{mark,favicon}}.svg and the
|
||||
// Android launcher icons; regenerate all of them together.
|
||||
//
|
||||
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
|
||||
// inherit currentColor, and inheriting it is the whole point: the hat takes
|
||||
// the surrounding text colour, so it reads on both the dark and light
|
||||
// palettes without a second asset. Parchment-on-parchment is invisible,
|
||||
// which is exactly the bug a fixed fill would reintroduce.
|
||||
//
|
||||
// The notes keep the accent in both modes — one of the places the design
|
||||
// system sanctions the accent (the wordmark).
|
||||
//
|
||||
// aria-hidden: every current use sits directly beside the words "Minstrel",
|
||||
// so labelling it would make a screen reader announce the name twice. A
|
||||
// STANDALONE use would need its own label.
|
||||
let {{ size = 28, class: klass = '' }}: {{ size?: number; class?: string }} = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="{vb}"
|
||||
width={{size * {w} / {h}}}
|
||||
height={{size}}
|
||||
class={{klass}}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g transform="{tr}" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="{hat}"/>
|
||||
<path fill="{accent}" d="{notes}"/>
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the fonts from Google Fonts and vendor them into the repo.
|
||||
|
||||
Covers BOTH clients — the web bundle (woff2, subsetted) and the Android app
|
||||
(static ttf, whole-font) — because they draw from the same three families and
|
||||
letting them drift is how one of them quietly stops matching the other.
|
||||
|
||||
Run by hand when the font set changes, never at build or run time:
|
||||
|
||||
python3 tools/vendor-fonts.py
|
||||
|
||||
The app must render itself with no outbound network, so nothing here may
|
||||
happen while it is running. This script fetches once, writes the woff2 files
|
||||
and a stylesheet that points at them by RELATIVE url, and everything is
|
||||
committed. `web/static/` is copied verbatim into the SvelteKit build, which
|
||||
Go then embeds — so the fonts travel inside the binary.
|
||||
|
||||
Relative urls in the stylesheet (`./Inter-latin.woff2`, not `/fonts/...`)
|
||||
are deliberate: a CSS url() resolves against the stylesheet's own address, so
|
||||
the whole directory keeps working when the app is served under a base path.
|
||||
|
||||
Subsets are kept as Google slices them, with their `unicode-range` intact.
|
||||
That is not a size compromise — the browser downloads only the ranges a page
|
||||
actually uses, so vendoring every subset costs repository bytes rather than
|
||||
request bytes, and a library full of non-English artist names renders instead
|
||||
of falling back mid-list.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "web/static/fonts"
|
||||
ANDROID_OUT = ROOT / "android/app/src/main/res/font"
|
||||
|
||||
# Matches the faces the design system actually permits: two weights only, 400
|
||||
# and 500 (never 600/700), and Fraunces' optical-size axis across its range.
|
||||
FAMILIES = (
|
||||
"family=Fraunces:opsz,wght@9..144,400;9..144,500"
|
||||
"&family=Inter:wght@400;500"
|
||||
"&family=JetBrains+Mono:wght@400;500"
|
||||
)
|
||||
CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap"
|
||||
|
||||
# The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
|
||||
# per client, and there is no parameter to ask for one directly.
|
||||
# modern Chrome -> woff2 (what the browser wants)
|
||||
# old Android -> ttf (what Android's res/font requires)
|
||||
# MSIE 6 -> eot (an IE-only format; the obvious "old UA" choice
|
||||
# and completely useless here — it downloads and
|
||||
# looks plausible until you check the magic bytes)
|
||||
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
|
||||
UA_TTF = ("Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) AppleWebKit/534.30 "
|
||||
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
|
||||
|
||||
# Android res/font resource names: lowercase, digits and underscore only.
|
||||
# Each entry is one static weight — Compose selects by the FontWeight declared
|
||||
# alongside it in Typography.kt, so the file must genuinely be that instance.
|
||||
# css2 collapses a multi-weight request to 400 for legacy clients, so each
|
||||
# weight is fetched on its own URL.
|
||||
ANDROID_FACES = [
|
||||
("fraunces_regular", "Fraunces:opsz,wght@9..144,400", 400),
|
||||
("fraunces_medium", "Fraunces:opsz,wght@9..144,500", 500),
|
||||
("inter_regular", "Inter:wght@400", 400),
|
||||
("inter_medium", "Inter:wght@500", 500),
|
||||
("jetbrains_mono_regular", "JetBrains+Mono:wght@400", 400),
|
||||
]
|
||||
|
||||
TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"OTTO")
|
||||
|
||||
|
||||
def weight_class(data):
|
||||
"""Read OS/2 usWeightClass out of a TrueType file.
|
||||
|
||||
Worth the twenty lines: css2 silently collapses a multi-weight request to
|
||||
400 for legacy clients, so asking for Medium and getting Regular is a real
|
||||
and quiet failure. The file downloads, has valid TrueType magic, and
|
||||
renders — just at the wrong weight, everywhere, forever. This is the only
|
||||
field that actually distinguishes them.
|
||||
"""
|
||||
count = struct.unpack(">H", data[4:6])[0]
|
||||
for i in range(count):
|
||||
off = 12 + i * 16
|
||||
if data[off:off + 4] == b"OS/2":
|
||||
table = struct.unpack(">I", data[off + 8:off + 12])[0]
|
||||
return struct.unpack(">H", data[table + 4:table + 6])[0]
|
||||
return None
|
||||
|
||||
|
||||
def fetch(url, timeout=30, ttf=False):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA_TTF if ttf else UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def vendor_android():
|
||||
"""Fetch static TTFs for the Android client's res/font."""
|
||||
ANDROID_OUT.mkdir(parents=True, exist_ok=True)
|
||||
for old in ANDROID_OUT.glob("*.ttf"):
|
||||
old.unlink()
|
||||
total = 0
|
||||
for name, spec, want_weight in ANDROID_FACES:
|
||||
css = fetch(f"https://fonts.googleapis.com/css2?family={spec}", ttf=True).decode()
|
||||
url = re.search(r"url\((https://[^)]+)\)", css)
|
||||
if not url:
|
||||
sys.exit(f"no font url for {spec}")
|
||||
data = fetch(url.group(1), ttf=True)
|
||||
# Assert the FORMAT, because every wrong one still downloads happily
|
||||
# and only fails later, on a device, as a silently missing typeface.
|
||||
if not data.startswith(TTF_MAGIC):
|
||||
sys.exit(f"{name}: expected TrueType, got magic {data[:4].hex()} "
|
||||
f"(eot/woff means the UA negotiation broke)")
|
||||
got = weight_class(data)
|
||||
if got != want_weight:
|
||||
sys.exit(f"{name}: wanted weight {want_weight}, file reports {got} "
|
||||
f"(css2 collapsed the request to a single weight)")
|
||||
(ANDROID_OUT / f"{name}.ttf").write_bytes(data)
|
||||
total += len(data)
|
||||
print(f" {name + '.ttf':<30} {len(data)/1024:7.1f} KB weight {got}")
|
||||
print(f"{len(ANDROID_FACES)} ttf, {total/1024/1024:.2f} MB total -> "
|
||||
f"{ANDROID_OUT.relative_to(ROOT)}")
|
||||
|
||||
|
||||
def main():
|
||||
css = fetch(CSS_URL).decode()
|
||||
|
||||
# Google emits a comment naming each subset before its @font-face block.
|
||||
blocks = re.split(r"(?=/\*\s*[a-z0-9-]+\s*\*/)", css)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for old in OUT.glob("*.woff2"):
|
||||
old.unlink()
|
||||
|
||||
out_css, seen, total = [], {}, 0
|
||||
for block in blocks:
|
||||
subset = re.search(r"/\*\s*([a-z0-9-]+)\s*\*/", block)
|
||||
family = re.search(r"font-family:\s*'([^']+)'", block)
|
||||
weight = re.search(r"font-weight:\s*([^;]+);", block)
|
||||
url = re.search(r"url\((https://[^)]+\.woff2)\)", block)
|
||||
if not (subset and family and url):
|
||||
continue
|
||||
w = weight.group(1).strip().replace(" ", "-") if weight else "400"
|
||||
name = f"{family.group(1).replace(' ', '')}-{w}-{subset.group(1)}.woff2"
|
||||
if name not in seen:
|
||||
data = fetch(url.group(1))
|
||||
(OUT / name).write_bytes(data)
|
||||
seen[name] = len(data)
|
||||
total += len(data)
|
||||
out_css.append(block.replace(url.group(1), f"./{name}"))
|
||||
|
||||
header = (
|
||||
"/* Generated by tools/vendor-fonts.py — do not edit by hand.\n"
|
||||
" *\n"
|
||||
" * Vendored from Google Fonts and served from our own origin: the app\n"
|
||||
" * has to render with no outbound network, so a font provider link is\n"
|
||||
" * not an option. Urls are relative so this keeps working under a base\n"
|
||||
" * path. unicode-range is preserved, so the browser still fetches only\n"
|
||||
" * the subsets a page needs.\n"
|
||||
" */\n"
|
||||
)
|
||||
(OUT / "fonts.css").write_text(header + "".join(out_css))
|
||||
for n, size in sorted(seen.items()):
|
||||
print(f" {n:<44} {size/1024:7.1f} KB")
|
||||
print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}")
|
||||
print()
|
||||
vendor_android()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!--
|
||||
SVG first: it flips the M with the viewer's colour scheme, which the PNG
|
||||
SVG first: it flips the hat with the viewer's colour scheme, which the PNG
|
||||
can't. The PNG is the fallback for browsers without SVG-favicon support
|
||||
and is plated for the same reason apple-touch-icon is — see brand/.
|
||||
Ordering matters: browsers take the last icon they understand, so the
|
||||
@@ -30,12 +30,12 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<!-- Fonts are vendored into web/static/fonts by tools/vendor-fonts.py and
|
||||
served from our own origin. A deployed instance has no outbound
|
||||
network, so a provider stylesheet would simply never arrive and the
|
||||
UI would render in fallback faces — hence no font-provider link and
|
||||
no preconnect to a host we don't run. -->
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
|
||||
%sveltekit.head%
|
||||
<!-- MINSTREL_TOKENS -->
|
||||
</head>
|
||||
|
||||
@@ -34,14 +34,17 @@ export type DiscoverTuning = {
|
||||
snooze_days: number;
|
||||
};
|
||||
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'songs_like' | 'taste' | 'discover';
|
||||
|
||||
/** The weight-profile scopes, as distinct from the singleton-settings scopes. */
|
||||
export type WeightProfileScope = 'radio' | 'daily_mix' | 'songs_like';
|
||||
|
||||
export type TuningSnapshot = {
|
||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||
profiles: Record<WeightProfileScope, WeightProfile>;
|
||||
taste: TasteTuning;
|
||||
discover: DiscoverTuning;
|
||||
shipped: {
|
||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||
profiles: Record<WeightProfileScope, WeightProfile>;
|
||||
taste: TasteTuning;
|
||||
discover: DiscoverTuning;
|
||||
};
|
||||
|
||||
@@ -1,57 +1,300 @@
|
||||
<script lang="ts">
|
||||
// The Minstrel mark: a Didone M whose right leg is an eighth note.
|
||||
// The Minstrel mark: a feathered bard's hat with an arc of notes overhead.
|
||||
//
|
||||
// Generated by tools/gen-brand-assets.py — do not edit the paths by hand.
|
||||
// The same silhouette ships in web/static/brand/{mark,favicon}.svg and the
|
||||
// Android launcher icons; regenerate all of them together.
|
||||
//
|
||||
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
|
||||
// inherit currentColor, and inheriting it is the whole point: the letter
|
||||
// takes the surrounding text colour, so it reads on both the dark and light
|
||||
// inherit currentColor, and inheriting it is the whole point: the hat takes
|
||||
// the surrounding text colour, so it reads on both the dark and light
|
||||
// palettes without a second asset. Parchment-on-parchment is invisible,
|
||||
// which is exactly the bug a fixed fill would reintroduce.
|
||||
//
|
||||
// The note keeps the accent in both modes — one of the places the design
|
||||
// The notes keep the accent in both modes — one of the places the design
|
||||
// system sanctions the accent (the wordmark).
|
||||
//
|
||||
// ⚠ These paths are duplicated in web/static/brand/favicon.svg and
|
||||
// web/static/brand/mark.svg, which need literal colours instead of
|
||||
// currentColor (a favicon has no cascade to inherit from). Change the
|
||||
// silhouette here, change it there.
|
||||
//
|
||||
// aria-hidden: every current use sits directly beside the words "Minstrel",
|
||||
// so labelling it would make a screen reader announce the name twice. A
|
||||
// STANDALONE use would need its own label.
|
||||
let { size = 20, class: klass = '' }: { size?: number; class?: string } = $props();
|
||||
let { size = 28, class: klass = '' }: { size?: number; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="202 251 902 723"
|
||||
width={size * 902 / 723}
|
||||
viewBox="608 592 3928 3568"
|
||||
width={size * 3928 / 3568}
|
||||
height={size}
|
||||
class={klass}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
<g transform="translate(0.000000,5016.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M17412 17089 c-134 -7 -375 -42 -530 -77 -90 -20 -238 -58 -296 -76 -13 -4 -60
|
||||
-19 -104 -32 -44 -14 -90 -28 -102 -32 -60 -21 -156 -55 -206 -75 -31 -12 -72
|
||||
-28 -92 -35 -20 -7 -56 -22 -80 -32 -24 -10 -50 -21 -58 -24 -17 -6 -79 -34 -133
|
||||
-59 -21 -10 -59 -27 -84 -38 -42 -19 -167 -81 -221 -111 -13 -7 -40 -21 -59 -32
|
||||
-35 -18 -44 -23 -169 -97 -371 -217 -625 -419 -799 -637 -69 -87 -107 -141 -152
|
||||
-216 -70 -117 -115 -204 -163 -314 -11 -26 -23 -54 -26 -61 -12 -26 -42 -108 -64
|
||||
-173 -48 -144 -92 -356 -106 -516 -3 -27 -6 -63 -8 -78 -2 -15 -4 -64 -4 -108 0
|
||||
-77 -2 -94 -11 -89 -5 3 -16 39 -29 91 -54 222 -65 467 -32 690 12 78 31 167 48
|
||||
220 40 125 60 175 113 286 25 53 28 61 18 63 -9 2 -145 -92 -227 -157 -25 -20
|
||||
-56 -44 -68 -54 -39 -30 -124 -107 -197 -180 -112 -109 -216 -229 -317 -364 -107
|
||||
-142 -234 -379 -307 -568 -69 -177 -120 -389 -135 -558 -2 -26 -6 -68 -8 -92 -7
|
||||
-80 -4 -224 8 -360 8 -89 28 -204 68 -392 8 -35 32 -125 40 -146 2 -5 8 -27 14
|
||||
-48 6 -21 20 -65 31 -97 10 -33 19 -62 19 -64 0 -8 -17 11 -34 37 -33 51 -118
|
||||
211 -158 298 -46 100 -102 256 -134 373 -56 211 -78 413 -67 635 4 78 17 180 30
|
||||
241 12 51 -5 41 -49 -31 -16 -25 -33 -52 -38 -60 -65 -100 -169 -296 -217 -408
|
||||
-42 -98 -90 -228 -118 -324 -37 -125 -87 -343 -103 -452 -22 -157 -30 -269 -30
|
||||
-446 0 -178 6 -273 26 -406 2 -14 8 -54 12 -88 8 -58 49 -272 63 -326 21 -87 73
|
||||
-265 94 -324 11 -31 29 -85 41 -120 12 -35 29 -83 38 -106 9 -23 24 -62 34 -86
|
||||
22 -56 49 -122 69 -168 9 -19 15 -37 14 -40 -3 -5 -26 22 -62 74 -17 25 -41 60
|
||||
-52 76 -21 30 -118 181 -134 208 -4 8 -21 35 -36 61 -37 60 -133 247 -170 329
|
||||
-54 123 -107 261 -131 345 -7 23 -10 29 -16 29 -9 0 -10 -3 -16 -68 -16 -187 -15
|
||||
-298 4 -473 14 -124 47 -294 94 -485 25 -97 27 -106 83 -274 57 -173 84 -249 124
|
||||
-348 16 -41 41 -103 55 -138 14 -35 45 -105 68 -156 24 -51 49 -106 57 -124 39
|
||||
-87 106 -217 205 -398 25 -45 53 -98 63 -117 11 -19 21 -36 23 -37 7 -4 221 -3
|
||||
225 1 2 3 6 20 7 40 2 19 6 44 8 55 2 11 5 31 6 44 1 13 6 49 10 80 4 31 12 83
|
||||
16 116 18 132 52 331 112 658 4 23 13 73 20 110 19 101 46 237 66 330 10 45 22
|
||||
99 26 120 20 91 35 161 48 214 15 62 33 136 40 162 4 14 21 79 37 144 17 65 35
|
||||
134 40 154 6 20 18 63 27 96 36 134 103 348 152 486 14 39 32 90 40 114 9 24 23
|
||||
65 32 90 9 25 24 66 32 90 8 24 24 65 34 90 10 25 28 72 40 104 26 70 43 112 119
|
||||
292 9 21 20 48 25 60 5 12 20 46 33 76 157 352 355 737 536 1042 76 127 135 224
|
||||
149 246 8 12 22 36 32 52 123 215 410 628 606 872 180 225 306 368 466 528 173
|
||||
173 333 313 513 449 72 55 91 67 91 58 0 -6 -8 -14 -123 -135 -81 -86 -168 -183
|
||||
-283 -316 -135 -156 -335 -426 -496 -668 -81 -123 -202 -319 -270 -441 -21 -36
|
||||
-52 -91 -70 -123 -52 -91 -166 -313 -209 -408 -22 -47 -48 -101 -57 -120 -9 -19
|
||||
-25 -54 -36 -78 -17 -39 -35 -79 -99 -224 -9 -20 -23 -54 -32 -76 -23 -55 -65
|
||||
-155 -87 -208 -19 -46 -48 -118 -80 -202 -10 -27 -27 -70 -36 -94 -10 -24 -29
|
||||
-76 -44 -116 -15 -40 -30 -81 -34 -92 -4 -11 -15 -42 -24 -68 -9 -26 -19 -54 -22
|
||||
-62 -6 -15 -26 -74 -50 -144 -8 -25 -23 -69 -33 -98 -30 -86 -124 -379 -151 -472
|
||||
-9 -31 -26 -87 -38 -124 -12 -37 -27 -89 -34 -114 -7 -25 -19 -66 -26 -90 -7 -24
|
||||
-17 -59 -22 -78 -9 -34 -28 -101 -47 -164 -5 -19 -15 -54 -21 -78 -13 -51 -28
|
||||
-105 -48 -180 -5 -18 -16 -59 -24 -92 -8 -33 -21 -81 -28 -106 -24 -87 -61 -229
|
||||
-92 -358 -11 -44 -25 -98 -30 -120 -21 -80 -86 -345 -94 -380 -12 -52 -17 -75
|
||||
-38 -158 -21 -87 -35 -147 -44 -186 -15 -67 -47 -211 -54 -244 -5 -20 -12 -50
|
||||
-16 -68 -24 -105 -33 -145 -42 -188 -6 -26 -13 -60 -16 -74 -9 -41 -19 -101 -20
|
||||
-116 0 -14 0 -14 58 -43 32 -16 61 -29 65 -29 4 0 23 24 52 67 55 80 95 133 200
|
||||
257 108 129 221 243 347 350 20 18 47 41 60 51 12 11 45 37 74 59 29 22 65 50 80
|
||||
61 34 26 135 97 182 128 19 12 58 38 88 57 30 19 63 41 74 48 11 7 39 25 62 39
|
||||
77 49 110 70 192 127 45 31 89 61 96 66 22 15 121 95 164 132 57 50 156 149 208
|
||||
209 57 64 151 193 193 263 31 51 38 68 34 73 -2 1 -43 -18 -92 -42 -49 -24 -100
|
||||
-48 -113 -54 -79 -34 -112 -48 -148 -60 -86 -31 -283 -95 -338 -111 -32 -8 -91
|
||||
-25 -132 -37 -78 -21 -317 -101 -398 -133 -26 -10 -64 -24 -84 -32 -20 -7 -48
|
||||
-19 -64 -25 -26 -12 -46 -17 -46 -10 0 4 12 13 74 60 87 65 101 74 216 143 99 59
|
||||
294 161 394 205 71 32 147 67 236 111 17 8 51 24 76 35 122 54 319 160 433 235
|
||||
150 97 274 196 384 307 124 125 209 240 286 385 20 38 53 108 63 136 3 8 13 37
|
||||
24 66 31 84 61 193 72 263 6 38 6 47 -2 47 -1 0 -17 -12 -34 -26 -39 -32 -127
|
||||
-96 -175 -127 -110 -72 -280 -155 -429 -211 -42 -15 -181 -58 -260 -79 -162 -43
|
||||
-418 -79 -644 -91 -105 -5 -130 -5 -130 2 0 7 16 12 67 24 176 40 443 130 669
|
||||
225 93 39 268 127 356 178 62 36 198 123 240 155 263 195 443 402 602 694 52 97
|
||||
104 228 139 354 15 53 36 152 51 246 20 123 31 317 18 322 -8 4 -15 -1 -29 -19
|
||||
-26 -33 -92 -101 -137 -141 -107 -96 -288 -218 -431 -290 -49 -24 -149 -71 -169
|
||||
-78 -8 -3 -27 -11 -44 -18 -16 -7 -34 -14 -40 -16 -5 -2 -23 -8 -38 -14 -67 -25
|
||||
-143 -50 -206 -68 -10 -3 -46 -13 -80 -23 -63 -19 -115 -32 -186 -47 -22 -5 -55
|
||||
-12 -74 -16 -19 -4 -47 -10 -62 -14 -15 -3 -44 -8 -64 -10 -20 -3 -57 -8 -82 -12
|
||||
-63 -10 -80 -12 -80 -8 0 6 15 16 42 26 15 6 56 24 90 40 34 16 91 42 126 57 95
|
||||
42 300 147 399 205 152 88 190 110 293 178 197 129 349 246 490 379 139 129 221
|
||||
225 306 357 159 244 256 534 310 924 3 26 7 50 26 174 6 42 19 112 34 187 15 78
|
||||
53 213 83 299 31 89 67 168 120 268 100 185 247 349 415 460 53 35 163 86 230
|
||||
106 62 19 57 22 -74 46 -83 15 -107 18 -190 24 -69 5 -270 7 -340 3z M10962
|
||||
13252 c-22 -4 -51 -10 -64 -14 -13 -4 -38 -11 -56 -17 -141 -42 -381 -164 -597
|
||||
-303 -12 -8 -31 -21 -44 -29 -71 -45 -259 -181 -345 -250 -207 -164 -321 -263
|
||||
-550 -480 -94 -88 -287 -283 -354 -358 -17 -18 -70 -76 -120 -130 -94 -102 -172
|
||||
-190 -256 -287 -27 -32 -62 -72 -78 -90 -16 -18 -61 -70 -100 -116 -155 -182
|
||||
-267 -303 -416 -450 -127 -125 -183 -178 -212 -202 -13 -11 -37 -32 -54 -46 -74
|
||||
-65 -108 -89 -108 -76 0 4 31 51 70 104 14 19 37 52 52 74 15 22 54 76 87 119 53
|
||||
72 67 95 55 95 -6 0 -79 -23 -140 -45 -239 -85 -448 -207 -599 -353 -44 -41 -107
|
||||
-111 -146 -160 -57 -73 -143 -211 -203 -330 -41 -80 -115 -241 -150 -326 -8 -20
|
||||
-20 -47 -26 -60 -26 -56 -106 -258 -163 -412 -34 -92 -132 -383 -161 -476 -72
|
||||
-235 -91 -299 -115 -394 -42 -157 -53 -203 -67 -268 -6 -27 -12 -56 -14 -64 -6
|
||||
-23 -16 -77 -16 -88 0 -12 23 -34 74 -67 71 -47 136 -74 276 -115 176 -51 387
|
||||
-77 708 -86 153 -5 357 -1 476 8 26 2 76 6 110 8 139 9 375 34 504 54 21 3 57 9
|
||||
80 12 82 11 129 18 197 30 38 7 78 14 90 16 11 2 36 6 54 10 18 3 57 11 87 16 63
|
||||
12 227 46 290 61 78 17 195 45 246 58 28 6 60 14 72 17 25 6 134 36 382 104 37
|
||||
10 76 21 86 24 41 11 281 85 380 116 250 79 681 228 878 302 54 20 195 73 344
|
||||
128 48 18 107 40 131 48 24 9 47 18 51 21 5 3 21 26 37 51 16 25 53 78 81 118 87
|
||||
122 116 167 147 231 54 109 73 181 73 281 0 90 -6 109 -96 298 -78 165 -170 375
|
||||
-229 524 -37 93 -108 303 -133 394 -36 130 -85 346 -102 446 -23 143 -30 193 -34
|
||||
242 -2 29 -6 77 -8 108 -8 91 -5 301 4 414 16 192 38 322 90 528 34 136 90 308
|
||||
130 399 32 75 95 200 116 235 9 14 16 28 16 32 0 10 -27 41 -100 116 -105 107
|
||||
-159 150 -247 193 -114 58 -250 78 -371 57z m-574 -653 c0 -3 -11 -19 -24 -35
|
||||
-56 -66 -193 -249 -280 -372 -11 -16 -37 -53 -58 -82 -36 -52 -121 -175 -270
|
||||
-396 -75 -111 -134 -197 -180 -262 -11 -16 -37 -53 -56 -82 -20 -29 -50 -72 -68
|
||||
-96 -17 -24 -51 -71 -75 -104 -23 -33 -53 -73 -67 -90 -13 -16 -45 -57 -70 -90
|
||||
-25 -33 -54 -71 -66 -84 -11 -14 -36 -44 -56 -68 -211 -257 -378 -416 -712 -676
|
||||
-202 -158 -451 -314 -678 -427 -89 -44 -108 -51 -108 -43 0 6 60 71 172 186 36
|
||||
37 101 106 144 152 43 46 93 99 110 118 35 36 141 155 190 212 17 19 46 53 66 75
|
||||
106 116 265 304 348 409 22 28 50 62 62 76 23 26 112 138 204 254 45 57 121 152
|
||||
238 294 56 69 157 186 190 222 14 15 39 42 54 60 81 93 266 279 418 420 102 95
|
||||
305 256 444 353 125 86 128 88 128 76z M15278 9526 c-254 -10 -537 -42 -734 -84
|
||||
-25 -6 -76 -16 -112 -24 -36 -8 -77 -17 -90 -20 -13 -3 -40 -9 -60 -14 -53 -12
|
||||
-94 -23 -148 -39 -26 -7 -67 -19 -90 -26 -105 -29 -86 -17 -169 -106 -108 -115
|
||||
-183 -200 -183 -207 0 -3 1 -6 3 -7 2 -1 60 12 129 30 238 60 436 99 672 133 378
|
||||
53 762 62 1100 26 394 -43 669 -112 950 -237 86 -38 120 -56 199 -104 127 -76
|
||||
190 -124 288 -220 56 -55 72 -75 59 -75 -3 0 -36 11 -75 24 -104 35 -245 74 -334
|
||||
92 -333 70 -595 86 -899 56 -432 -43 -871 -187 -1332 -438 -230 -125 -518 -327
|
||||
-752 -525 -121 -103 -206 -178 -288 -255 -42 -39 -93 -86 -112 -104 -177 -163
|
||||
-532 -518 -810 -808 -46 -48 -129 -135 -184 -192 -55 -58 -148 -155 -207 -216
|
||||
-100 -105 -332 -337 -427 -428 -81 -78 -251 -228 -320 -282 -287 -228 -476 -351
|
||||
-804 -523 -150 -78 -582 -266 -840 -365 -243 -93 -485 -179 -676 -241 -74 -24
|
||||
-251 -78 -274 -85 -11 -3 -36 -10 -56 -16 -106 -31 -150 -44 -280 -80 -47 -13
|
||||
-154 -41 -238 -60 -23 -6 -56 -14 -74 -18 -49 -12 -99 -23 -152 -34 -26 -5 -77
|
||||
-16 -113 -24 -67 -15 -315 -60 -391 -72 -24 -4 -62 -10 -84 -14 -52 -10 -236 -33
|
||||
-245 -31 -14 3 25 18 115 45 164 49 282 91 490 173 160 63 238 98 416 183 31 15
|
||||
73 35 93 45 168 79 470 256 681 398 361 243 556 410 872 745 370 392 569 586 811
|
||||
794 96 82 247 200 349 274 154 112 324 220 486 308 97 53 271 133 347 160 36 13
|
||||
31 15 98 -39 67 -55 115 -85 214 -132 36 -17 125 -51 135 -51 14 0 12 8 -3 17
|
||||
-85 50 -155 106 -231 184 -78 80 -123 140 -167 230 -67 131 -93 230 -104 379 -10
|
||||
146 6 266 54 414 47 145 132 289 232 396 80 84 173 154 279 208 60 31 97 45 164
|
||||
64 90 25 155 34 256 34 89 0 130 -5 210 -22 159 -36 312 -120 437 -242 73 -71
|
||||
127 -143 178 -242 24 -45 36 -73 65 -154 9 -25 18 -35 27 -28 4 2 29 20 56 40 28
|
||||
20 52 40 55 45 5 11 -14 67 -52 154 -62 139 -127 230 -246 343 -51 49 -83 74
|
||||
-130 105 -131 85 -243 133 -370 159 -57 12 -167 24 -215 24 -43 0 -151 -9 -193
|
||||
-16 -197 -33 -383 -125 -554 -272 -110 -94 -212 -233 -282 -384 -36 -76 -52 -123
|
||||
-78 -226 -28 -109 -38 -196 -35 -317 2 -72 1 -86 -4 -91 -3 -3 -6 -6 -6 -6 -1 0
|
||||
-28 -10 -97 -35 -24 -9 -67 -24 -96 -35 -126 -45 -218 -78 -238 -86 -12 -5 -27
|
||||
-10 -32 -12 -6 -2 -36 -13 -66 -25 -78 -30 -130 -50 -214 -81 -55 -20 -147 -55
|
||||
-232 -88 -69 -26 -139 -54 -188 -72 -71 -27 -114 -43 -168 -64 -30 -12 -76 -30
|
||||
-102 -40 -80 -31 -85 -33 -194 -76 -58 -23 -124 -48 -146 -56 -22 -8 -73 -28
|
||||
-114 -44 -41 -16 -91 -36 -112 -44 -21 -8 -50 -19 -64 -25 -14 -5 -38 -14 -52
|
||||
-20 -14 -5 -41 -16 -60 -23 -19 -8 -51 -20 -72 -28 -21 -8 -49 -19 -62 -24 -13
|
||||
-5 -46 -18 -74 -28 -27 -10 -65 -25 -84 -32 -19 -7 -47 -18 -62 -24 -15 -6 -36
|
||||
-14 -46 -18 -10 -4 -31 -12 -46 -18 -15 -6 -34 -13 -42 -16 -8 -3 -25 -9 -38 -14
|
||||
-13 -5 -30 -11 -38 -14 -8 -3 -61 -23 -118 -44 -57 -22 -113 -43 -124 -47 -11 -4
|
||||
-69 -25 -128 -47 -59 -22 -118 -43 -130 -48 -18 -7 -73 -27 -198 -72 -11 -4 -35
|
||||
-13 -54 -20 -19 -7 -69 -25 -112 -41 -43 -15 -97 -34 -120 -43 -23 -8 -67 -24
|
||||
-98 -35 -31 -11 -66 -24 -78 -29 -27 -10 -65 -24 -118 -42 -22 -7 -74 -25 -116
|
||||
-40 -42 -15 -91 -32 -110 -38 -19 -6 -47 -16 -64 -22 -16 -6 -61 -21 -98 -34 -37
|
||||
-13 -113 -39 -168 -58 -55 -19 -160 -54 -234 -79 -74 -24 -162 -53 -196 -65 -66
|
||||
-23 -147 -50 -304 -101 -54 -18 -151 -50 -216 -71 -65 -21 -164 -53 -220 -71
|
||||
-224 -70 -333 -103 -388 -119 -32 -9 -81 -24 -110 -33 -59 -18 -258 -73 -362
|
||||
-101 -38 -10 -102 -27 -142 -38 -367 -99 -775 -177 -1038 -196 -98 -8 -291 -7
|
||||
-360 1 -187 23 -313 72 -405 158 -49 46 -73 87 -87 147 -10 40 -10 52 0 100 43
|
||||
209 194 467 432 740 139 159 315 325 508 480 88 71 120 95 250 191 56 41 254 173
|
||||
304 203 16 10 46 29 67 41 41 27 244 142 307 176 172 90 263 134 484 231 46 20
|
||||
129 52 202 78 88 31 137 49 145 54 5 2 18 22 29 45 31 59 73 139 100 186 24 41
|
||||
27 50 20 55 -5 3 -108 -15 -168 -30 -25 -6 -56 -13 -68 -16 -100 -22 -195 -52
|
||||
-362 -111 -217 -78 -354 -137 -565 -242 -216 -109 -445 -244 -633 -374 -169 -116
|
||||
-247 -176 -402 -305 -78 -65 -78 -64 -205 -186 -201 -190 -371 -390 -508 -594
|
||||
-62 -93 -121 -196 -178 -309 -19 -37 -23 -47 -60 -138 -39 -94 -61 -169 -81 -269
|
||||
-30 -159 -29 -288 2 -424 23 -101 47 -163 97 -254 139 -249 392 -463 778 -657 66
|
||||
-34 155 -75 203 -94 34 -13 55 -22 82 -33 35 -14 183 -68 218 -80 15 -5 43 -14
|
||||
62 -19 19 -6 52 -16 74 -23 84 -26 175 -51 248 -68 104 -25 98 -24 220 -50 306
|
||||
-65 634 -107 994 -126 100 -5 385 -11 458 -9 144 3 321 10 372 13 137 9 232 16
|
||||
288 22 232 23 225 22 460 52 169 22 440 68 672 114 33 6 70 13 82 16 32 6 187 40
|
||||
282 62 24 6 59 14 78 18 19 4 66 15 104 24 39 9 81 19 94 22 68 15 513 133 614
|
||||
163 19 6 57 17 84 25 28 8 70 21 94 28 24 7 79 23 122 36 43 12 89 26 102 30 13
|
||||
4 48 15 78 24 168 52 316 100 446 146 42 15 107 37 144 50 132 45 359 128 502
|
||||
183 61 23 141 53 178 67 37 14 91 35 118 46 28 11 73 29 100 40 99 38 343 137
|
||||
458 186 171 72 333 141 384 164 31 14 79 35 108 47 54 23 129 56 222 99 30 14 86
|
||||
39 124 56 39 17 118 54 177 82 59 27 108 50 109 50 1 0 46 22 101 48 54 27 122
|
||||
60 151 73 98 46 561 281 637 324 17 10 64 35 105 57 344 182 837 498 1136 730
|
||||
178 137 220 171 330 264 68 57 138 116 204 170 27 22 62 51 78 64 17 14 53 42 82
|
||||
64 29 22 77 59 108 82 120 91 338 241 431 297 14 9 62 38 106 65 104 64 270 158
|
||||
363 207 40 21 92 49 117 62 25 14 77 41 116 60 39 20 116 58 171 86 55 27 114 56
|
||||
130 63 17 7 65 31 109 52 43 22 97 48 120 58 73 33 280 130 304 143 15 8 24 15
|
||||
25 21 3 12 -21 90 -44 140 -22 48 -70 133 -106 188 -43 65 -110 143 -189 220
|
||||
-117 115 -171 158 -301 237 -91 55 -165 95 -220 120 -24 11 -54 24 -66 30 -71 32
|
||||
-189 73 -324 112 -54 16 -219 52 -294 65 -109 18 -272 36 -380 42 -88 4 -259 8
|
||||
-312 6z M12694 8423 c-3 -7 -6 -19 -8 -27 -5 -24 -26 -84 -47 -132 -43 -96 -79
|
||||
-152 -141 -214 -80 -81 -161 -130 -292 -178 -26 -9 -40 -16 -40 -20 0 -4 12 -10
|
||||
36 -17 161 -51 276 -136 366 -270 42 -63 97 -192 120 -281 9 -35 20 -34 28 2 10
|
||||
46 53 165 78 212 44 86 102 158 170 214 70 57 129 89 222 120 52 17 55 23 20 33
|
||||
-118 35 -241 107 -308 180 -78 84 -148 224 -177 359 -8 35 -17 42 -27 19z M5933
|
||||
7589 c-7 -5 -39 -97 -53 -154 -7 -27 -15 -59 -18 -71 -16 -59 -50 -198 -58 -230
|
||||
-4 -20 -16 -73 -26 -118 -19 -86 -24 -107 -40 -196 -29 -151 -30 -170 -18 -231
|
||||
25 -131 110 -235 254 -311 83 -44 266 -98 352 -104 32 -3 32 -3 96 23 35 14 91
|
||||
36 124 49 80 31 216 86 258 104 19 8 55 23 80 33 55 21 143 57 185 75 16 7 83 34
|
||||
148 60 64 25 122 48 127 51 9 4 70 28 144 58 17 7 61 24 98 39 37 15 97 38 132
|
||||
52 73 29 135 53 216 84 31 12 68 26 82 32 14 6 58 22 96 37 64 24 131 50 246 95
|
||||
24 10 71 28 104 40 33 12 66 25 74 28 8 3 27 10 42 16 15 6 34 13 42 16 19 8 98
|
||||
38 144 55 61 23 177 66 194 71 9 3 48 17 88 32 40 15 93 34 118 42 53 17 92 34
|
||||
92 39 0 6 -35 2 -148 -15 -21 -3 -69 -10 -106 -14 -37 -5 -91 -12 -120 -16 -223
|
||||
-33 -612 -69 -900 -84 -387 -19 -756 -12 -1049 20 -168 18 -373 60 -496 100 -165
|
||||
54 -271 105 -368 177 -43 32 -106 90 -116 108 -7 11 -11 13 -20 8z"/>
|
||||
<path fill="#4A6B5C" d="M11642 17427 c4 -332 10 -651 14 -799 7 -251 10 -586 5 -589 -2 -2 -16 2 -31 7
|
||||
-43 15 -81 19 -148 17 -47 -1 -68 -4 -100 -12 -101 -26 -179 -72 -254 -147 -67
|
||||
-67 -115 -151 -141 -248 -7 -28 -9 -45 -9 -92 0 -73 10 -119 38 -178 25 -50 48
|
||||
-79 89 -110 124 -94 277 -104 429 -29 169 84 285 250 316 453 7 45 8 269 2 534
|
||||
-2 92 -5 254 -6 359 -3 182 -3 191 4 194 4 1 11 0 17 -3 5 -3 26 -17 48 -30 117
|
||||
-70 178 -120 234 -190 68 -85 98 -225 73 -346 -13 -62 -52 -158 -78 -190 -12 -16
|
||||
-20 -29 -20 -36 0 -25 127 73 179 137 40 51 79 136 102 225 42 157 12 352 -73
|
||||
488 -71 112 -130 174 -264 274 -104 78 -211 178 -269 252 -31 40 -97 143 -124
|
||||
193 -15 26 -21 35 -28 35 -8 0 -8 -1 -5 -169z M9054 17478 c-44 -17 -84 -32 -90
|
||||
-34 -5 -2 -34 -13 -64 -24 -30 -11 -90 -34 -134 -50 -92 -34 -137 -51 -188 -71
|
||||
-32 -13 -121 -46 -204 -76 -42 -16 -95 -36 -165 -63 -35 -14 -69 -27 -77 -30 -78
|
||||
-29 -158 -59 -196 -74 -16 -6 -62 -23 -102 -38 -40 -15 -82 -30 -94 -35 -12 -4
|
||||
-28 -10 -36 -13 -8 -3 -45 -17 -82 -31 -98 -39 -154 -60 -201 -77 -62 -22 -59
|
||||
-18 -58 -86 1 -32 3 -89 5 -128 2 -38 6 -140 8 -226 2 -86 6 -186 8 -222 2 -36 6
|
||||
-125 8 -198 2 -73 7 -195 10 -272 3 -77 8 -199 10 -272 2 -73 6 -162 8 -198 6
|
||||
-101 5 -198 -1 -203 -4 -3 -11 -2 -27 3 -29 10 -89 19 -136 23 -73 4 -157 -13
|
||||
-231 -49 -132 -63 -232 -160 -302 -294 -54 -103 -78 -243 -61 -348 16 -92 49
|
||||
-158 110 -220 45 -46 94 -75 164 -98 32 -11 38 -12 106 -12 66 0 75 1 112 11 95
|
||||
25 152 54 220 111 117 99 204 229 239 360 28 104 32 152 27 330 -2 77 -6 208 -8
|
||||
292 -2 84 -6 207 -8 274 -2 67 -7 208 -10 312 -3 105 -8 237 -10 294 -8 244 -9
|
||||
300 -3 307 5 6 26 16 65 29 9 3 38 14 64 24 26 10 85 32 130 48 45 17 105 39 132
|
||||
50 28 11 82 32 122 46 90 33 112 41 134 50 27 11 86 34 118 46 15 6 34 13 42 16
|
||||
19 8 77 30 154 58 75 28 161 60 210 80 58 23 159 57 170 58 12 0 12 0 15 -34 1
|
||||
-19 2 -71 3 -116 0 -78 3 -202 12 -494 2 -73 5 -212 6 -309 3 -204 7 -188 -38
|
||||
-173 -87 27 -205 27 -295 -1 -167 -53 -300 -170 -379 -333 -64 -133 -74 -282 -26
|
||||
-407 32 -82 102 -158 182 -197 61 -30 98 -39 171 -41 50 -2 64 -1 99 7 155 33
|
||||
282 127 389 285 51 77 87 173 106 287 5 36 6 66 6 202 -1 88 -3 235 -5 326 -6
|
||||
240 -15 700 -18 860 -1 76 -4 212 -6 302 -2 90 -5 234 -6 320 -2 93 -5 158 -7
|
||||
161 -2 3 -7 5 -10 5 -4 0 -43 -14 -87 -30z M10590 16917 c-241 -7 -571 -39 -792
|
||||
-76 -20 -3 -48 -7 -62 -9 -59 -9 -147 -29 -177 -41 -54 -21 -82 -53 -117 -131
|
||||
-27 -61 -25 -67 22 -58 96 19 176 34 190 36 9 1 54 8 100 16 199 32 325 50 476
|
||||
64 33 3 76 8 96 10 166 19 676 42 990 46 76 1 78 1 79 9 3 19 -49 74 -84 89 -39
|
||||
17 -188 37 -327 44 -76 4 -295 4 -394 1z M12612 16699 c-3 -5 -3 -12 2 -32 3 -14
|
||||
7 -38 8 -54 4 -35 9 -41 45 -49 12 -3 47 -11 77 -19 30 -7 76 -19 102 -26 105
|
||||
-26 423 -127 520 -165 26 -10 57 -22 68 -26 56 -20 134 -52 186 -75 32 -14 74
|
||||
-32 94 -40 20 -7 89 -40 153 -72 64 -32 118 -57 119 -56 5 4 -39 38 -122 93 -47
|
||||
31 -100 65 -118 76 -18 11 -35 22 -38 24 -3 3 -22 13 -42 24 -20 11 -50 27 -68
|
||||
37 -53 30 -201 103 -260 128 -48 21 -131 54 -236 95 -24 10 -209 70 -260 86 -22
|
||||
6 -219 56 -224 56 0 0 -3 -2 -6 -5z M7107 16128 c-9 -5 -50 -24 -92 -43 -323
|
||||
-143 -696 -369 -1031 -626 -221 -170 -375 -306 -558 -494 -111 -115 -112 -116
|
||||
-93 -140 6 -6 22 -29 36 -51 39 -59 40 -60 50 -56 5 2 42 35 82 72 203 190 371
|
||||
336 516 449 58 45 189 143 234 174 18 12 46 32 62 44 28 21 70 49 173 115 29 18
|
||||
64 41 78 50 14 9 47 29 72 44 25 15 57 34 70 42 33 20 113 66 206 119 44 24 88
|
||||
50 98 58 11 7 35 24 53 37 18 12 41 30 51 39 18 17 19 18 22 49 3 18 4 53 3 78
|
||||
-1 52 -3 55 -32 40z M10520 15886 c-173 -8 -506 -53 -752 -102 -120 -24 -324 -75
|
||||
-333 -83 -7 -6 -7 -14 -6 -92 0 -59 2 -88 5 -92 5 -6 11 -5 75 10 317 80 557 129
|
||||
797 163 163 23 352 47 410 51 32 3 57 7 61 10 3 3 10 16 15 30 5 14 17 40 26 58
|
||||
20 36 21 44 11 46 -8 2 -265 2 -309 1z M12215 15662 c-35 -7 -64 -14 -65 -15 -7
|
||||
-7 3 -15 25 -19 13 -2 46 -10 75 -16 29 -6 64 -14 78 -16 32 -6 243 -58 314 -78
|
||||
145 -41 396 -121 476 -151 112 -43 195 -71 205 -71 7 0 20 8 36 20 28 22 32 29
|
||||
25 38 -7 9 -174 90 -254 124 -174 74 -276 111 -414 151 -86 25 -89 26 -205 37
|
||||
-147 14 -213 13 -296 -4z M4890 15080 c-8 -13 -6 -751 6 -2098 2 -253 3 -483 1
|
||||
-512 -4 -61 1 -57 -55 -38 -75 26 -179 34 -265 22 -80 -12 -190 -56 -270 -108
|
||||
-89 -56 -190 -168 -241 -264 -60 -114 -78 -180 -81 -300 -1 -61 -1 -83 5 -112 12
|
||||
-67 38 -129 81 -194 63 -95 171 -162 292 -182 214 -34 443 73 605 283 93 120 153
|
||||
267 172 421 3 25 4 228 3 690 0 360 0 742 0 850 1 108 1 200 1 205 0 6 2 9 7 9 6
|
||||
0 108 -66 205 -132 102 -70 218 -182 280 -270 132 -187 162 -383 91 -596 -31 -93
|
||||
-101 -215 -158 -275 -24 -25 -28 -35 -15 -35 10 0 96 43 123 61 94 64 168 140
|
||||
219 227 23 37 69 140 84 185 32 96 46 187 46 309 0 121 -11 186 -50 305 -33 100
|
||||
-73 178 -148 287 -55 82 -102 140 -174 216 -117 125 -256 277 -304 332 -158 184
|
||||
-324 442 -415 646 -29 63 -32 69 -38 71 -2 1 -6 -1 -7 -3z M6454 14216 c-4 -5
|
||||
-30 -27 -56 -49 -162 -137 -326 -292 -326 -310 0 -5 11 -29 25 -54 14 -25 34 -64
|
||||
44 -87 10 -23 21 -44 24 -46 5 -3 35 24 125 111 108 105 152 145 251 231 19 16
|
||||
35 33 36 37 2 4 -3 14 -12 24 -23 28 -50 71 -68 111 -19 42 -28 49 -43 32z M4552
|
||||
13379 c-42 -45 -92 -104 -122 -143 -16 -21 -44 -57 -62 -80 -51 -65 -155 -224
|
||||
-237 -360 -26 -43 -73 -127 -160 -286 -82 -152 -244 -485 -311 -640 -26 -60 -68
|
||||
-161 -76 -184 -4 -11 -11 -30 -16 -42 -8 -21 -21 -56 -61 -168 -10 -27 -34 -99
|
||||
-53 -158 -19 -59 -36 -112 -38 -118 -2 -5 -6 -20 -10 -32 -3 -12 -14 -53 -24 -90
|
||||
-27 -101 -59 -231 -78 -326 -15 -70 -48 -260 -60 -338 -50 -343 -62 -711 -32
|
||||
-1028 10 -108 22 -209 30 -254 12 -65 39 -198 46 -232 4 -19 12 -56 18 -82 12
|
||||
-55 16 -69 46 -175 11 -38 72 -221 86 -256 14 -37 36 -96 49 -127 5 -14 24 -57
|
||||
40 -94 56 -126 184 -370 194 -370 7 0 -2 30 -33 102 -39 93 -68 173 -118 332 -10
|
||||
33 -21 67 -24 76 -12 35 -34 119 -68 256 -25 104 -49 226 -74 376 -20 125 -23
|
||||
156 -36 300 -11 120 -16 255 -16 400 0 314 25 562 88 879 7 33 15 73 18 90 20
|
||||
104 73 321 115 461 32 110 105 326 145 430 5 13 15 39 22 58 7 19 18 46 25 60 12
|
||||
26 12 26 13 138 0 97 2 118 9 160 27 144 62 233 136 348 44 67 121 150 207 222
|
||||
36 30 36 30 118 174 78 136 124 212 202 336 117 185 143 228 156 267 5 14 5 21 0
|
||||
55 -11 66 -16 86 -23 87 -5 1 -16 -8 -31 -24z M4548 11050 c-43 -28 -87 -50 -120
|
||||
-59 -47 -14 -47 -14 -60 -34 -36 -51 -112 -312 -159 -541 -36 -173 -50 -258 -69
|
||||
-422 -19 -166 -28 -402 -20 -546 11 -213 12 -221 40 -432 9 -70 54 -300 71 -366
|
||||
50 -190 136 -430 215 -598 84 -179 112 -228 129 -228 10 0 29 16 29 24 0 3 -11
|
||||
30 -24 61 -74 171 -142 387 -200 631 -15 62 -53 270 -64 348 -41 293 -46 648 -14
|
||||
964 12 113 55 379 82 504 35 163 93 371 156 559 24 73 44 137 44 141 0 12 -11 10
|
||||
-36 -6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
// is actually running (came up debugging the in-app update flow when
|
||||
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
|
||||
//
|
||||
// This is now the ONLY place an operator can see which build they are on.
|
||||
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
|
||||
// are rolling names and :<sha> answers "which commit", not "which build" —
|
||||
// so the server's self-report is the answer.
|
||||
//
|
||||
// The channel is shown BESIDE the version, never spliced into it: the same
|
||||
// commit built on both lanes reports an identical version and differs only
|
||||
// in channel, so "2026.09.10.1449 · dev" and "2026.09.10.1449 · stable" are
|
||||
// the same code on two lines. Suppressed for stable, which is the
|
||||
// unremarkable case and would just be noise on every install.
|
||||
//
|
||||
// /healthz is unauthenticated, so the bare fetch works without
|
||||
// credentials. Renders nothing on parse failure or pre-version
|
||||
// images that don't include the field — graceful degradation.
|
||||
|
||||
type Health = { status: string; version?: string };
|
||||
type Health = { status: string; version?: string; channel?: string };
|
||||
|
||||
let version = $state<string | null>(null);
|
||||
let channel = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/healthz');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as Partial<Health>;
|
||||
if (body.version && body.version !== 'dev') {
|
||||
version = body.version;
|
||||
} else if (body.version === 'dev') {
|
||||
// Local dev images report "dev" — show it so the operator
|
||||
// can tell they're not on a release tag.
|
||||
version = 'dev';
|
||||
}
|
||||
if (!body.version) return;
|
||||
version = body.version;
|
||||
// Reported verbatim rather than validated against an enum — a build
|
||||
// claiming something unexpected is better shown than dropped.
|
||||
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
|
||||
} catch {
|
||||
// network / parse error — silent.
|
||||
}
|
||||
@@ -33,5 +43,7 @@
|
||||
</script>
|
||||
|
||||
{#if version}
|
||||
<p class="text-xs text-text-secondary">Server {version}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Server {version}{#if channel} · {channel}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
|
||||
>
|
||||
<MinstrelMark size={20} class="shrink-0" />
|
||||
<MinstrelMark size={28} class="shrink-0" />
|
||||
{appName()}
|
||||
</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
// A deployed instance has no outbound network, so every asset the app draws
|
||||
// itself with has to come from our own origin. This regressed once already
|
||||
// (the font stylesheet was linked straight from Google), and the failure is
|
||||
// invisible in development — the dev machine HAS internet, so the fonts load
|
||||
// fine and only a real deployment renders in fallback faces.
|
||||
//
|
||||
// The assertion is on the PROPERTY, not on a vendor: any absolute URL in a
|
||||
// resource-loading attribute fails, whoever is hosting it. Naming
|
||||
// `fonts.googleapis.com` would pass the day someone reached for a different
|
||||
// CDN, which is the same bug.
|
||||
|
||||
// fileURLToPath rather than import.meta.dirname: the latter needs Node 20.11+
|
||||
// and this repo's toolchain is still on 18, where it is silently undefined.
|
||||
const WEB = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
/** Strip comments before asserting an ABSENCE — otherwise prose describing
|
||||
* the forbidden thing satisfies the check that forbids it. */
|
||||
const stripComments = (html: string) => html.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
/** href/src on anything that makes the browser fetch a subresource. */
|
||||
const RESOURCE_URL = /<(?:link|script|img|source|iframe)\b[^>]*?\b(?:href|src)\s*=\s*["']([^"']+)["']/gi;
|
||||
|
||||
const externalUrlsIn = (html: string) =>
|
||||
[...stripComments(html).matchAll(RESOURCE_URL)]
|
||||
.map((m) => m[1])
|
||||
.filter((u) => /^(?:https?:)?\/\//i.test(u));
|
||||
|
||||
describe('the app ships every asset it draws itself with', () => {
|
||||
test('app.html loads no subresource from a third-party origin', () => {
|
||||
const html = readFileSync(join(WEB, 'src/app.html'), 'utf8');
|
||||
expect(externalUrlsIn(html)).toEqual([]);
|
||||
});
|
||||
|
||||
test('app.html does not preconnect or dns-prefetch to any host', () => {
|
||||
// preconnect carries no href-fetch of its own, so the check above misses
|
||||
// it — but it is the fingerprint of a third-party asset about to be added.
|
||||
const html = stripComments(readFileSync(join(WEB, 'src/app.html'), 'utf8'));
|
||||
const hints = [...html.matchAll(/<link\b[^>]*\brel\s*=\s*["']([^"']+)["'][^>]*>/gi)]
|
||||
.filter((m) => /\b(?:preconnect|dns-prefetch)\b/i.test(m[1]));
|
||||
expect(hints.map((m) => m[0])).toEqual([]);
|
||||
});
|
||||
|
||||
test('the vendored font stylesheet exists and points only at local files', () => {
|
||||
const dir = join(WEB, 'static/fonts');
|
||||
const css = readFileSync(join(dir, 'fonts.css'), 'utf8');
|
||||
|
||||
const urls = [...css.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/gi)].map((m) => m[1]);
|
||||
expect(urls.length).toBeGreaterThan(0);
|
||||
expect(urls.filter((u) => /^(?:https?:)?\/\//i.test(u))).toEqual([]);
|
||||
|
||||
// Every referenced file is actually present — a stylesheet pointing at a
|
||||
// woff2 nobody committed fails exactly like a CDN link would, offline.
|
||||
const present = new Set(readdirSync(dir));
|
||||
const missing = urls
|
||||
.map((u) => u.replace(/^\.\//, ''))
|
||||
.filter((f) => !present.has(f));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('every font family the tokens name is actually vendored', () => {
|
||||
// Pins the families to the TOKENS rather than to a hardcoded list, so
|
||||
// swapping a typeface in tokens.json fails here until it is vendored.
|
||||
const tokens = JSON.parse(
|
||||
readFileSync(join(WEB, 'src/lib/styles/tokens.json'), 'utf8')
|
||||
) as { fonts: Record<string, string> };
|
||||
const files = readdirSync(join(WEB, 'static/fonts'));
|
||||
for (const family of Object.values(tokens.fonts)) {
|
||||
const prefix = `${family.replace(/\s+/g, '')}-`;
|
||||
expect(
|
||||
files.some((f) => f.startsWith(prefix) && f.endsWith('.woff2')),
|
||||
`no vendored woff2 for "${family}"`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
resetTuning,
|
||||
getTrends,
|
||||
type TuningScope,
|
||||
type WeightProfileScope,
|
||||
type TuningSnapshot,
|
||||
type WeightProfile,
|
||||
type TasteTuning,
|
||||
@@ -49,9 +50,15 @@
|
||||
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
|
||||
];
|
||||
|
||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||
const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [
|
||||
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
|
||||
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
|
||||
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' },
|
||||
{
|
||||
scope: 'songs_like',
|
||||
label: 'Songs like…',
|
||||
blurb:
|
||||
'The tightest surface: everything here should sound like the seed track. Similarity dominates on purpose — raising like/taste/recency here is what makes these mixes wander.'
|
||||
}
|
||||
];
|
||||
|
||||
let snapshot = $state<TuningSnapshot | null>(null);
|
||||
@@ -64,10 +71,11 @@
|
||||
const f: Record<string, Record<string, string>> = {
|
||||
radio: {},
|
||||
daily_mix: {},
|
||||
songs_like: {},
|
||||
taste: {},
|
||||
discover: {}
|
||||
};
|
||||
for (const p of ['radio', 'daily_mix'] as const) {
|
||||
for (const p of profileScopes.map((s) => s.scope)) {
|
||||
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
|
||||
}
|
||||
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
|
||||
|
||||
@@ -55,11 +55,19 @@ const discover = (over: Partial<Record<string, number>> = {}) => ({
|
||||
// new scopes to BOTH this fixture and `shipped`.
|
||||
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
||||
return {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
profiles: {
|
||||
radio: weights({ taste_weight: 1 }),
|
||||
daily_mix: weights(),
|
||||
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
|
||||
},
|
||||
taste: taste(),
|
||||
discover: discover(),
|
||||
shipped: {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
profiles: {
|
||||
radio: weights({ taste_weight: 1 }),
|
||||
daily_mix: weights(),
|
||||
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
|
||||
},
|
||||
taste: taste(),
|
||||
discover: discover()
|
||||
},
|
||||
@@ -75,11 +83,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('Admin tuning page', () => {
|
||||
test('renders both profiles and the taste card with current values', async () => {
|
||||
test('renders every weight profile and the taste card with current values', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Songs like…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
|
||||
const radioTaste = screen.getByLabelText(/taste weight/i, {
|
||||
selector: '#radio-taste_weight'
|
||||
@@ -89,6 +98,27 @@ describe('Admin tuning page', () => {
|
||||
expect(halfLife.value).toBe('75');
|
||||
});
|
||||
|
||||
// Songs-like is a separate weight profile precisely so it can be tuned
|
||||
// apart from For-You (#3881). If its card stopped rendering its OWN values
|
||||
// — or quietly fell back to daily_mix's — the split would exist in the
|
||||
// backend and be unreachable in the UI, which is the same as not shipping
|
||||
// it (rule 27).
|
||||
test('the songs-like card carries its own weights, not daily_mix\'s', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Songs like…')).toBeInTheDocument());
|
||||
|
||||
const sim = document.getElementById('songs_like-similarity_weight') as HTMLInputElement;
|
||||
const like = document.getElementById('songs_like-like_boost') as HTMLInputElement;
|
||||
expect(sim.value).toBe('4');
|
||||
expect(like.value).toBe('0.5');
|
||||
|
||||
// The contrast that makes the card worth having: daily_mix must still show
|
||||
// its own, different numbers on the same page.
|
||||
const dailySim = document.getElementById('daily_mix-similarity_weight') as HTMLInputElement;
|
||||
expect(dailySim.value).not.toBe(sim.value);
|
||||
});
|
||||
|
||||
test('save sends only the changed fields for the scope', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
|
||||
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,35 +1,219 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="157 116 992 992">
|
||||
<!-- Minstrel mark. The M flips with the viewer's scheme because a favicon
|
||||
sits on browser chrome we don't control: parchment would vanish on a
|
||||
light tab strip, obsidian on a dark one. The note keeps the accent in
|
||||
both — teal holds against either. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="72 72 2384 1488">
|
||||
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
|
||||
The REDUCED mark, because a tab renders this at 16px.
|
||||
The hat flips with the viewer's scheme because a favicon sits on
|
||||
browser chrome we don't control: parchment would vanish on a light
|
||||
tab strip, obsidian on a dark one. The plume holds the accent in
|
||||
both — teal reads against either (3.04:1 on obsidian, 5.3:1 on
|
||||
the light ground). -->
|
||||
<style>
|
||||
.m { fill: #E8E4D8; }
|
||||
@media (prefers-color-scheme: light) { .m { fill: #14171A; } }
|
||||
svg { color: #E8E4D8; }
|
||||
@media (prefers-color-scheme: light) { svg { color: #14171A; } }
|
||||
</style>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path class="m" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
<g transform="translate(0.000000,2560.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M3904 8952 c-8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -7 -6 -15 -8 -32 -8 -17 0
|
||||
-25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32 -8 -21 0
|
||||
-24 -1 -40 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -8 0 -16 -8 -16 -17 0 -9 -38 -47 -48 -47
|
||||
-10 0 -80 -70 -80 -80 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8
|
||||
-16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16
|
||||
-24 -9 -9 -16 -20 -16 -24 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -7
|
||||
-15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4
|
||||
-4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0
|
||||
-4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0 -9 -3 -18 -8 -24 -4 -5 -8 -12 -8 -16 0
|
||||
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
|
||||
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -4 -5 -8 -12 -8 -16 0
|
||||
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
|
||||
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0
|
||||
-17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8 -64
|
||||
0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8
|
||||
-64 0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -8 -9 -8 -15
|
||||
-8 -104 0 -89 0 -95 -8 -104 -8 -9 -8 -15 -8 -496 0 -481 0 -487 8 -496 8 -9 8
|
||||
-15 8 -160 0 -145 0 -151 8 -160 8 -9 8 -15 8 -128 0 -113 0 -119 8 -128 7 -8 8
|
||||
-15 8 -56 0 -47 0 -47 -12 -60 -12 -11 -14 -12 -44 -12 -25 0 -32 -1 -40 -8 -8
|
||||
-7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8
|
||||
-7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
|
||||
-8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1
|
||||
-40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3
|
||||
-24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2
|
||||
-32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -7 -6 -15 -8 -32 -8 -17 0 -25
|
||||
-2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
|
||||
-3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
|
||||
-3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0
|
||||
-25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9
|
||||
0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -18 -11 -32 -24 -14 -13 -28 -24 -32 -24 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32
|
||||
-8 -17 0 -25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -13 -8 -17
|
||||
-8 -4 0 -27 -20 -51 -44 -44 -44 -44 -44 -44 -92 0 -48 0 -48 36 -84 20 -20 39
|
||||
-36 43 -36 4 0 12 -4 17 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 7 -6 15 -8 32 -8
|
||||
17 0 25 -2 32 -8 8 -7 15 -8 40 -8 25 0 32 -1 40 -8 8 -7 15 -8 56 -8 41 0 48 -1
|
||||
56 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 8 -7
|
||||
15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56
|
||||
-8 41 0 48 -1 56 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56
|
||||
-1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8
|
||||
-7 15 -8 64 -8 49 0 56 -1 64 -8 9 -8 15 -8 144 -8 129 0 135 0 144 -8 8 -7 15
|
||||
-8 72 -8 57 0 64 -1 72 -8 9 -8 15 -8 152 -8 137 0 143 0 152 -8 9 -8 15 -8 160
|
||||
-8 145 0 151 0 160 -8 9 -8 15 -8 576 -8 561 0 567 0 576 8 9 8 15 8 176 8 161 0
|
||||
167 0 176 8 9 8 15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 8
|
||||
15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 7 15 8 88 8 73 0
|
||||
79 1 88 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 9 7 15 8
|
||||
88 8 73 0 79 1 88 8 8 7 15 8 80 8 65 0 72 1 80 8 9 7 15 8 88 8 73 0 79 1 88 8
|
||||
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
|
||||
32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 80 8 65 0 72 1 80 8 8 7 15 8
|
||||
40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 40 8 25 0 32 1 40 8
|
||||
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
|
||||
32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 8 7
|
||||
15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1
|
||||
40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
|
||||
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
|
||||
15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24
|
||||
8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18
|
||||
3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
|
||||
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
|
||||
15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24
|
||||
8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8 16 8 4 0
|
||||
11 4 16 8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8
|
||||
16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5
|
||||
15 8 24 8 14 0 19 3 48 32 18 18 36 32 40 32 10 0 128 118 128 128 0 4 7 15 16
|
||||
24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16 4 5 8 12 8 16
|
||||
0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 7 8 8 15 8 48 0 33 1 40 8 48 7 8 8
|
||||
15 8 40 0 25 -1 32 -8 40 -7 8 -8 15 -8 40 0 25 -1 32 -8 40 -4 5 -8 12 -8 16 0
|
||||
4 -3 11 -7 14 -4 4 -8 15 -9 25 -1 11 -5 19 -8 21 -18 8 -22 12 -24 21 -2 5 -10
|
||||
17 -17 25 -8 8 -15 18 -15 22 0 8 -8 16 -16 16 -4 0 -18 11 -32 24 -14 13 -28 24
|
||||
-32 24 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -9 8 -15 8 -368 8
|
||||
-353 0 -359 0 -368 -8 -9 -8 -15 -8 -296 -8 -281 0 -287 0 -296 -8 -9 -8 -15 -8
|
||||
-160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8
|
||||
-15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -151 0 -151 0 -164
|
||||
12 -6 7 -12 16 -12 20 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4
|
||||
-29 37 -64 72 -36 36 -64 68 -64 72 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 11
|
||||
-134 144 -144 144 -5 0 -33 25 -72 64 -37 37 -68 64 -72 64 -4 0 -18 11 -32 24
|
||||
-14 13 -28 24 -32 24 -4 0 -35 27 -72 64 -38 38 -67 64 -72 64 -10 0 -48 38 -48
|
||||
48 0 10 -54 64 -64 64 -10 0 -80 70 -80 80 0 10 -22 32 -32 32 -4 0 -17 9 -28 20
|
||||
-11 11 -20 24 -20 28 0 4 -6 13 -12 20 -7 6 -16 12 -20 12 -10 0 -48 38 -48 48 0
|
||||
4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -18 26 -40 48 -22 22 -40 44 -40 48 0 4
|
||||
-18 26 -40 48 -22 22 -40 44 -40 48 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
|
||||
11 -8 16 -4 5 -8 12 -8 16 0 4 -14 22 -32 40 -18 18 -32 36 -32 40 0 4 -4 11 -8
|
||||
16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
|
||||
11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4
|
||||
5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -5 6 -8 15 -8
|
||||
24 0 9 -3 18 -8 24 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11
|
||||
-8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -11 18 -24 32 -13 14 -24 28 -24 32 0 4
|
||||
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -16 16 -10 0 -32 23 -32 32 0 4
|
||||
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -17 16 -3 0 -28 22 -55 48 -27 27
|
||||
-52 48 -56 48 -4 0 -22 14 -40 32 -18 18 -36 32 -40 32 -4 0 -11 4 -16 8 -5 4
|
||||
-12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16
|
||||
8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -15
|
||||
7 -24 16 -9 9 -20 16 -24 16 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8
|
||||
-7 6 -15 8 -32 8 -17 0 -25 2 -32 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -7 6 -15
|
||||
8 -32 8 -17 0 -25 2 -32 8 -8 7 -15 8 -48 8 -33 0 -40 1 -48 8 -9 8 -15 8 -104 8
|
||||
-89 0 -95 0 -104 -8z m3115 -3165 c8 -8 6 -48 -3 -59 -4 -5 -8 -13 -8 -17 0 -4
|
||||
-16 -23 -36 -43 -20 -20 -39 -36 -43 -36 -4 0 -12 -4 -17 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8
|
||||
-9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8
|
||||
-17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -8 -7 -15 -8 -64 -8
|
||||
-49 0 -56 -1 -64 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32
|
||||
-8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8
|
||||
-72 -8 -57 0 -64 -1 -72 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15
|
||||
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7
|
||||
-15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8
|
||||
-7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8
|
||||
-9 -7 -15 -8 -88 -8 -73 0 -79 -1 -88 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
|
||||
-8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -9 -7 -15 -8 -88 -8 -73 0 -79 -1
|
||||
-88 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40
|
||||
-1 -48 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8 -33 0
|
||||
-40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25
|
||||
0 -32 -1 -40 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8
|
||||
-33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48
|
||||
-8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8
|
||||
-48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15
|
||||
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -72 -8 -57 0 -64 -1 -72 -8 -5 -4
|
||||
-12 -8 -16 -8 -4 0 -9 -3 -12 -6 -3 -3 -8 -6 -12 -6 -8 0 -8 7 -8 193 0 201 0
|
||||
203 16 203 4 0 15 7 24 16 17 16 17 16 64 16 41 0 48 1 56 8 6 5 15 8 24 8 9 0
|
||||
18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 8
|
||||
7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 7 6 15 8 32 8 17 0 25
|
||||
2 32 8 8 7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 64 8
|
||||
49 0 56 1 64 8 8 7 15 8 72 8 57 0 64 1 72 8 8 7 15 8 80 8 65 0 72 1 80 8 7 6
|
||||
15 8 32 8 17 0 25 2 32 8 8 7 15 8 72 8 57 0 64 1 72 8 9 8 15 8 112 8 97 0 103
|
||||
0 112 8 8 7 15 8 80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 8 7 15 8
|
||||
80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 9 8 15 8 144 8 129 0 135 0
|
||||
144 8 9 8 15 8 136 8 121 0 127 0 136 8 9 8 15 8 128 8 113 0 119 0 128 8 9 8 15
|
||||
8 152 8 137 0 143 0 152 8 9 8 15 8 120 8 80 0 112 -1 115 -5z"/>
|
||||
<path fill="#4A6B5C" d="M9328 9816 c-5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -13 0 -19 -3 -40 -24 -21 -21 -27 -24 -40 -24
|
||||
-12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16
|
||||
-8 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -6 -5 -15 -8
|
||||
-24 -8 -12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9
|
||||
-20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16
|
||||
-9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7
|
||||
-24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0
|
||||
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
|
||||
-4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -22 -14 -40 -32 -18 -18 -36 -32
|
||||
-40 -32 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -26 -18 -48 -40 -22 -22
|
||||
-44 -40 -48 -40 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -18 -11 -32 -24
|
||||
-14 -13 -28 -24 -32 -24 -4 0 -26 -18 -48 -40 -22 -22 -44 -40 -48 -40 -4 0 -29
|
||||
-21 -56 -48 -27 -26 -52 -48 -56 -48 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
|
||||
-4 0 -29 -22 -56 -48 -28 -27 -52 -48 -56 -48 -10 0 -48 -38 -48 -48 0 -10 -54
|
||||
-64 -64 -64 -5 0 -36 -29 -80 -72 -43 -43 -75 -72 -80 -72 -10 0 -48 -38 -48 -48
|
||||
0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -10 -54 -64 -64 -64 -10 0 -48
|
||||
-38 -48 -48 0 -5 -32 -40 -80 -88 -47 -47 -80 -83 -80 -88 0 -4 -11 -18 -24 -32
|
||||
-13 -14 -24 -28 -24 -32 0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -4 -11
|
||||
-18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -24 -32 -56 -64 -32 -32 -56 -60 -56
|
||||
-64 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14
|
||||
-24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -4 -11 -8
|
||||
-16 -4 -5 -8 -12 -8 -15 0 -4 -3 -10 -6 -14 -8 -8 -5 -19 5 -19 4 0 12 -4 17 -8
|
||||
5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8
|
||||
16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11
|
||||
-4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5
|
||||
-4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16
|
||||
-8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4
|
||||
16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4
|
||||
12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8
|
||||
4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24
|
||||
-8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12
|
||||
-8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0
|
||||
11 -4 16 -8 5 -4 12 -8 16 -8 4 0 9 -3 12 -6 3 -3 8 -6 11 -6 9 0 25 19 25 28 0
|
||||
10 54 64 64 64 4 0 18 11 32 24 14 13 28 24 32 24 10 0 48 38 48 48 0 10 54 64
|
||||
64 64 11 0 416 405 416 416 0 10 54 64 64 64 10 0 48 38 48 48 0 5 29 37 72 80
|
||||
42 42 72 75 72 80 0 4 11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40
|
||||
44 40 48 0 4 21 29 48 56 26 27 48 52 48 56 0 4 7 15 16 24 9 9 16 20 16 24 0 4
|
||||
11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40 44 40 48 0 4 7 15 16 24
|
||||
9 9 16 20 16 24 0 4 11 18 24 32 13 14 24 28 24 32 0 4 7 15 16 24 9 9 16 20 16
|
||||
24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16
|
||||
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7
|
||||
15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16
|
||||
20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4
|
||||
11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16
|
||||
0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16
|
||||
20 16 24 0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16
|
||||
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11
|
||||
8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 5 6 8 15 8 24 0 9
|
||||
3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 7 8 8 15 8 40 0 25 1 32 8 40 5 6 8 15 8
|
||||
24 0 9 3 18 8 24 6 7 8 15 8 32 0 17 2 25 8 32 6 7 8 15 8 31 0 15 2 25 6 30 12
|
||||
12 5 19 -21 19 -18 0 -26 -2 -33 -8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,211 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="72 72 2384 1488">
|
||||
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
|
||||
The reduced mark, literal colours. For use at or below ~24px,
|
||||
where the full art collapses into a diagonal smear. -->
|
||||
<g transform="translate(0.000000,2560.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
|
||||
<path fill="#E8E4D8" d="M3904 8952 c-8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -7 -6 -15 -8 -32 -8 -17 0
|
||||
-25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32 -8 -21 0
|
||||
-24 -1 -40 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -8 0 -16 -8 -16 -17 0 -9 -38 -47 -48 -47
|
||||
-10 0 -80 -70 -80 -80 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8
|
||||
-16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16
|
||||
-24 -9 -9 -16 -20 -16 -24 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -7
|
||||
-15 -16 -24 -9 -9 -16 -20 -16 -24 0 -4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4
|
||||
-4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -7 -15 -16 -24 -9 -9 -16 -20 -16 -24 0
|
||||
-4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0 -9 -3 -18 -8 -24 -4 -5 -8 -12 -8 -16 0
|
||||
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
|
||||
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -4 -5 -8 -12 -8 -16 0
|
||||
-4 -4 -11 -8 -16 -4 -5 -8 -12 -8 -16 0 -4 -4 -11 -8 -16 -5 -6 -8 -15 -8 -24 0
|
||||
-9 -3 -18 -8 -24 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0
|
||||
-17 -2 -25 -8 -32 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8 -64
|
||||
0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -7 -8 -8 -15 -8
|
||||
-64 0 -49 -1 -56 -8 -64 -6 -7 -8 -15 -8 -32 0 -17 -2 -25 -8 -32 -8 -9 -8 -15
|
||||
-8 -104 0 -89 0 -95 -8 -104 -8 -9 -8 -15 -8 -496 0 -481 0 -487 8 -496 8 -9 8
|
||||
-15 8 -160 0 -145 0 -151 8 -160 8 -9 8 -15 8 -128 0 -113 0 -119 8 -128 7 -8 8
|
||||
-15 8 -56 0 -47 0 -47 -12 -60 -12 -11 -14 -12 -44 -12 -25 0 -32 -1 -40 -8 -8
|
||||
-7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8
|
||||
-7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
|
||||
-8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1
|
||||
-40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3
|
||||
-24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2
|
||||
-32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -7 -6 -15 -8 -32 -8 -17 0 -25
|
||||
-2 -32 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
|
||||
-3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18
|
||||
-3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0
|
||||
-25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0
|
||||
-18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9
|
||||
0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4
|
||||
0 -18 -11 -32 -24 -14 -13 -28 -24 -32 -24 -4 0 -11 -4 -16 -8 -7 -6 -15 -8 -32
|
||||
-8 -17 0 -25 -2 -32 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -13 -8 -17
|
||||
-8 -4 0 -27 -20 -51 -44 -44 -44 -44 -44 -44 -92 0 -48 0 -48 36 -84 20 -20 39
|
||||
-36 43 -36 4 0 12 -4 17 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 7 -6 15 -8 32 -8
|
||||
17 0 25 -2 32 -8 8 -7 15 -8 40 -8 25 0 32 -1 40 -8 8 -7 15 -8 56 -8 41 0 48 -1
|
||||
56 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 8 -7
|
||||
15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56 -8 41 0 48 -1 56 -8 8 -7 15 -8 56
|
||||
-8 41 0 48 -1 56 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56
|
||||
-1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8 -7 15 -8 64 -8 49 0 56 -1 64 -8 8
|
||||
-7 15 -8 64 -8 49 0 56 -1 64 -8 9 -8 15 -8 144 -8 129 0 135 0 144 -8 8 -7 15
|
||||
-8 72 -8 57 0 64 -1 72 -8 9 -8 15 -8 152 -8 137 0 143 0 152 -8 9 -8 15 -8 160
|
||||
-8 145 0 151 0 160 -8 9 -8 15 -8 576 -8 561 0 567 0 576 8 9 8 15 8 176 8 161 0
|
||||
167 0 176 8 9 8 15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 8
|
||||
15 8 168 8 153 0 159 0 168 8 9 7 15 8 88 8 73 0 79 1 88 8 9 7 15 8 88 8 73 0
|
||||
79 1 88 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 9 7 15 8
|
||||
88 8 73 0 79 1 88 8 8 7 15 8 80 8 65 0 72 1 80 8 9 7 15 8 88 8 73 0 79 1 88 8
|
||||
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
|
||||
32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 80 8 65 0 72 1 80 8 8 7 15 8
|
||||
40 8 25 0 32 1 40 8 8 7 15 8 48 8 33 0 40 1 48 8 8 7 15 8 40 8 25 0 32 1 40 8
|
||||
8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0
|
||||
32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 8 7
|
||||
15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 40 8 25 0 32 1
|
||||
40 8 8 7 15 8 40 8 25 0 32 1 40 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
|
||||
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
|
||||
15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24
|
||||
8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18
|
||||
3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9
|
||||
0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 6 5
|
||||
15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24
|
||||
8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8 16 8 4 0
|
||||
11 4 16 8 5 4 12 8 16 8 4 0 15 7 24 16 9 9 20 16 24 16 4 0 11 4 16 8 5 4 12 8
|
||||
16 8 4 0 11 4 16 8 6 5 15 8 24 8 9 0 18 3 24 8 5 4 12 8 16 8 4 0 11 4 16 8 6 5
|
||||
15 8 24 8 14 0 19 3 48 32 18 18 36 32 40 32 10 0 128 118 128 128 0 4 7 15 16
|
||||
24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16 4 5 8 12 8 16
|
||||
0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 7 8 8 15 8 48 0 33 1 40 8 48 7 8 8
|
||||
15 8 40 0 25 -1 32 -8 40 -7 8 -8 15 -8 40 0 25 -1 32 -8 40 -4 5 -8 12 -8 16 0
|
||||
4 -3 11 -7 14 -4 4 -8 15 -9 25 -1 11 -5 19 -8 21 -18 8 -22 12 -24 21 -2 5 -10
|
||||
17 -17 25 -8 8 -15 18 -15 22 0 8 -8 16 -16 16 -4 0 -18 11 -32 24 -14 13 -28 24
|
||||
-32 24 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -9 8 -15 8 -368 8
|
||||
-353 0 -359 0 -368 -8 -9 -8 -15 -8 -296 -8 -281 0 -287 0 -296 -8 -9 -8 -15 -8
|
||||
-160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8
|
||||
-15 -8 -160 -8 -145 0 -151 0 -160 -8 -9 -8 -15 -8 -160 -8 -151 0 -151 0 -164
|
||||
12 -6 7 -12 16 -12 20 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4
|
||||
-29 37 -64 72 -36 36 -64 68 -64 72 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 11
|
||||
-134 144 -144 144 -5 0 -33 25 -72 64 -37 37 -68 64 -72 64 -4 0 -18 11 -32 24
|
||||
-14 13 -28 24 -32 24 -4 0 -35 27 -72 64 -38 38 -67 64 -72 64 -10 0 -48 38 -48
|
||||
48 0 10 -54 64 -64 64 -10 0 -80 70 -80 80 0 10 -22 32 -32 32 -4 0 -17 9 -28 20
|
||||
-11 11 -20 24 -20 28 0 4 -6 13 -12 20 -7 6 -16 12 -20 12 -10 0 -48 38 -48 48 0
|
||||
4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -18 26 -40 48 -22 22 -40 44 -40 48 0 4
|
||||
-18 26 -40 48 -22 22 -40 44 -40 48 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
|
||||
11 -8 16 -4 5 -8 12 -8 16 0 4 -14 22 -32 40 -18 18 -32 36 -32 40 0 4 -4 11 -8
|
||||
16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9 -16 20 -16 24 0 4 -4
|
||||
11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4
|
||||
5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -5 6 -8 15 -8
|
||||
24 0 9 -3 18 -8 24 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11
|
||||
-8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -7 15 -16 24 -9 9
|
||||
-16 20 -16 24 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -4 11 -8 16 -4 5 -8 12 -8
|
||||
16 0 4 -4 11 -8 16 -4 5 -8 12 -8 16 0 4 -11 18 -24 32 -13 14 -24 28 -24 32 0 4
|
||||
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -16 16 -10 0 -32 23 -32 32 0 4
|
||||
-11 18 -24 32 -13 14 -24 28 -24 32 0 8 -8 16 -17 16 -3 0 -28 22 -55 48 -27 27
|
||||
-52 48 -56 48 -4 0 -22 14 -40 32 -18 18 -36 32 -40 32 -4 0 -11 4 -16 8 -5 4
|
||||
-12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16
|
||||
8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -15
|
||||
7 -24 16 -9 9 -20 16 -24 16 -4 0 -11 4 -16 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8
|
||||
-7 6 -15 8 -32 8 -17 0 -25 2 -32 8 -5 4 -12 8 -16 8 -4 0 -11 4 -16 8 -7 6 -15
|
||||
8 -32 8 -17 0 -25 2 -32 8 -8 7 -15 8 -48 8 -33 0 -40 1 -48 8 -9 8 -15 8 -104 8
|
||||
-89 0 -95 0 -104 -8z m3115 -3165 c8 -8 6 -48 -3 -59 -4 -5 -8 -13 -8 -17 0 -4
|
||||
-16 -23 -36 -43 -20 -20 -39 -36 -43 -36 -4 0 -12 -4 -17 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8
|
||||
-4 0 -11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8
|
||||
-9 0 -18 -3 -24 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -7 -6 -15 -8 -32 -8
|
||||
-17 0 -25 -2 -32 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -8 -7 -15 -8 -64 -8
|
||||
-49 0 -56 -1 -64 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32
|
||||
-8 -17 0 -25 -2 -32 -8 -7 -6 -15 -8 -32 -8 -17 0 -25 -2 -32 -8 -8 -7 -15 -8
|
||||
-72 -8 -57 0 -64 -1 -72 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15
|
||||
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7
|
||||
-15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8
|
||||
-7 -15 -8 -80 -8 -65 0 -72 -1 -80 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8
|
||||
-9 -7 -15 -8 -88 -8 -73 0 -79 -1 -88 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40
|
||||
-8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -9 -7 -15 -8 -88 -8 -73 0 -79 -1
|
||||
-88 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40
|
||||
-1 -48 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8 -33 0
|
||||
-40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25
|
||||
0 -32 -1 -40 -8 -9 -7 -15 -8 -96 -8 -81 0 -87 -1 -96 -8 -8 -7 -15 -8 -48 -8
|
||||
-33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48
|
||||
-8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8
|
||||
-48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15 -8 -48 -8 -33 0 -40 -1 -48 -8 -8 -7 -15
|
||||
-8 -40 -8 -25 0 -32 -1 -40 -8 -8 -7 -15 -8 -72 -8 -57 0 -64 -1 -72 -8 -5 -4
|
||||
-12 -8 -16 -8 -4 0 -9 -3 -12 -6 -3 -3 -8 -6 -12 -6 -8 0 -8 7 -8 193 0 201 0
|
||||
203 16 203 4 0 15 7 24 16 17 16 17 16 64 16 41 0 48 1 56 8 6 5 15 8 24 8 9 0
|
||||
18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 6 5 15 8
|
||||
24 8 9 0 18 3 24 8 7 6 15 8 32 8 17 0 25 2 32 8 7 6 15 8 32 8 17 0 25 2 32 8 8
|
||||
7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 7 6 15 8 32 8 17 0 25
|
||||
2 32 8 8 7 15 8 64 8 49 0 56 1 64 8 8 7 15 8 40 8 25 0 32 1 40 8 8 7 15 8 64 8
|
||||
49 0 56 1 64 8 8 7 15 8 72 8 57 0 64 1 72 8 8 7 15 8 80 8 65 0 72 1 80 8 7 6
|
||||
15 8 32 8 17 0 25 2 32 8 8 7 15 8 72 8 57 0 64 1 72 8 9 8 15 8 112 8 97 0 103
|
||||
0 112 8 8 7 15 8 80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 8 7 15 8
|
||||
80 8 65 0 72 1 80 8 9 8 15 8 104 8 89 0 95 0 104 8 9 8 15 8 144 8 129 0 135 0
|
||||
144 8 9 8 15 8 136 8 121 0 127 0 136 8 9 8 15 8 128 8 113 0 119 0 128 8 9 8 15
|
||||
8 152 8 137 0 143 0 152 8 9 8 15 8 120 8 80 0 112 -1 115 -5z"/>
|
||||
<path fill="#4A6B5C" d="M9328 9816 c-5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -9 0 -18 -3 -24 -8 -5 -4 -12 -8 -16 -8 -4 0
|
||||
-11 -4 -16 -8 -6 -5 -15 -8 -24 -8 -13 0 -19 -3 -40 -24 -21 -21 -27 -24 -40 -24
|
||||
-12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16
|
||||
-8 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -11 -4 -16 -8 -6 -5 -15 -8
|
||||
-24 -8 -12 0 -18 -3 -32 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9
|
||||
-20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16
|
||||
-9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7
|
||||
-24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0
|
||||
-15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
|
||||
-4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -22 -14 -40 -32 -18 -18 -36 -32
|
||||
-40 -32 -4 0 -11 -4 -16 -8 -5 -4 -12 -8 -16 -8 -4 0 -26 -18 -48 -40 -22 -22
|
||||
-44 -40 -48 -40 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16 -4 0 -18 -11 -32 -24
|
||||
-14 -13 -28 -24 -32 -24 -4 0 -26 -18 -48 -40 -22 -22 -44 -40 -48 -40 -4 0 -29
|
||||
-21 -56 -48 -27 -26 -52 -48 -56 -48 -4 0 -15 -7 -24 -16 -9 -9 -20 -16 -24 -16
|
||||
-4 0 -29 -22 -56 -48 -28 -27 -52 -48 -56 -48 -10 0 -48 -38 -48 -48 0 -10 -54
|
||||
-64 -64 -64 -5 0 -36 -29 -80 -72 -43 -43 -75 -72 -80 -72 -10 0 -48 -38 -48 -48
|
||||
0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -10 -54 -64 -64 -64 -10 0 -48
|
||||
-38 -48 -48 0 -5 -32 -40 -80 -88 -47 -47 -80 -83 -80 -88 0 -4 -11 -18 -24 -32
|
||||
-13 -14 -24 -28 -24 -32 0 -10 -54 -64 -64 -64 -10 0 -48 -38 -48 -48 0 -4 -11
|
||||
-18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -24 -32 -56 -64 -32 -32 -56 -60 -56
|
||||
-64 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14
|
||||
-24 -28 -24 -32 0 -4 -11 -18 -24 -32 -13 -14 -24 -28 -24 -32 0 -4 -4 -11 -8
|
||||
-16 -4 -5 -8 -12 -8 -15 0 -4 -3 -10 -6 -14 -8 -8 -5 -19 5 -19 4 0 12 -4 17 -8
|
||||
5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8
|
||||
16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11
|
||||
-4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5
|
||||
-4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16
|
||||
-8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4
|
||||
16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4
|
||||
12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8
|
||||
4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 6 -5 15 -8 24 -8 9 0 18 -3 24
|
||||
-8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12
|
||||
-8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0 11 -4 16 -8 5 -4 12 -8 16 -8 4 0
|
||||
11 -4 16 -8 5 -4 12 -8 16 -8 4 0 9 -3 12 -6 3 -3 8 -6 11 -6 9 0 25 19 25 28 0
|
||||
10 54 64 64 64 4 0 18 11 32 24 14 13 28 24 32 24 10 0 48 38 48 48 0 10 54 64
|
||||
64 64 11 0 416 405 416 416 0 10 54 64 64 64 10 0 48 38 48 48 0 5 29 37 72 80
|
||||
42 42 72 75 72 80 0 4 11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40
|
||||
44 40 48 0 4 21 29 48 56 26 27 48 52 48 56 0 4 7 15 16 24 9 9 16 20 16 24 0 4
|
||||
11 18 24 32 13 14 24 28 24 32 0 4 18 26 40 48 22 22 40 44 40 48 0 4 7 15 16 24
|
||||
9 9 16 20 16 24 0 4 11 18 24 32 13 14 24 28 24 32 0 4 7 15 16 24 9 9 16 20 16
|
||||
24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4 11 8 16
|
||||
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7
|
||||
15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16
|
||||
20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 7 15 16 24 9 9 16 20 16 24 0 4 4
|
||||
11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16
|
||||
0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 7 15 16 24 9 9 16
|
||||
20 16 24 0 4 4 11 8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16
|
||||
4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11 8 16 4 5 8 12 8 16 0 4 4 11
|
||||
8 16 5 6 8 15 8 24 0 9 3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 5 6 8 15 8 24 0 9
|
||||
3 18 8 24 4 5 8 12 8 16 0 4 4 11 8 16 7 8 8 15 8 40 0 25 1 32 8 40 5 6 8 15 8
|
||||
24 0 9 3 18 8 24 6 7 8 15 8 32 0 17 2 25 8 32 6 7 8 15 8 31 0 15 2 25 6 30 12
|
||||
12 5 19 -21 19 -18 0 -26 -2 -33 -8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -1,27 +1,274 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="202 251 902 723">
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="608 592 3928 3568">
|
||||
<!-- Generated by tools/gen-brand-assets.py — do not edit by hand.
|
||||
Literal colours, for any consumer that cannot inline the SVG and
|
||||
therefore cannot supply a currentColor. -->
|
||||
<g transform="translate(0.000000,5016.000000) scale(0.250000,-0.250000)" fill-rule="evenodd">
|
||||
<path fill="#E8E4D8" d="M17412 17089 c-134 -7 -375 -42 -530 -77 -90 -20 -238 -58 -296 -76 -13 -4 -60
|
||||
-19 -104 -32 -44 -14 -90 -28 -102 -32 -60 -21 -156 -55 -206 -75 -31 -12 -72
|
||||
-28 -92 -35 -20 -7 -56 -22 -80 -32 -24 -10 -50 -21 -58 -24 -17 -6 -79 -34 -133
|
||||
-59 -21 -10 -59 -27 -84 -38 -42 -19 -167 -81 -221 -111 -13 -7 -40 -21 -59 -32
|
||||
-35 -18 -44 -23 -169 -97 -371 -217 -625 -419 -799 -637 -69 -87 -107 -141 -152
|
||||
-216 -70 -117 -115 -204 -163 -314 -11 -26 -23 -54 -26 -61 -12 -26 -42 -108 -64
|
||||
-173 -48 -144 -92 -356 -106 -516 -3 -27 -6 -63 -8 -78 -2 -15 -4 -64 -4 -108 0
|
||||
-77 -2 -94 -11 -89 -5 3 -16 39 -29 91 -54 222 -65 467 -32 690 12 78 31 167 48
|
||||
220 40 125 60 175 113 286 25 53 28 61 18 63 -9 2 -145 -92 -227 -157 -25 -20
|
||||
-56 -44 -68 -54 -39 -30 -124 -107 -197 -180 -112 -109 -216 -229 -317 -364 -107
|
||||
-142 -234 -379 -307 -568 -69 -177 -120 -389 -135 -558 -2 -26 -6 -68 -8 -92 -7
|
||||
-80 -4 -224 8 -360 8 -89 28 -204 68 -392 8 -35 32 -125 40 -146 2 -5 8 -27 14
|
||||
-48 6 -21 20 -65 31 -97 10 -33 19 -62 19 -64 0 -8 -17 11 -34 37 -33 51 -118
|
||||
211 -158 298 -46 100 -102 256 -134 373 -56 211 -78 413 -67 635 4 78 17 180 30
|
||||
241 12 51 -5 41 -49 -31 -16 -25 -33 -52 -38 -60 -65 -100 -169 -296 -217 -408
|
||||
-42 -98 -90 -228 -118 -324 -37 -125 -87 -343 -103 -452 -22 -157 -30 -269 -30
|
||||
-446 0 -178 6 -273 26 -406 2 -14 8 -54 12 -88 8 -58 49 -272 63 -326 21 -87 73
|
||||
-265 94 -324 11 -31 29 -85 41 -120 12 -35 29 -83 38 -106 9 -23 24 -62 34 -86
|
||||
22 -56 49 -122 69 -168 9 -19 15 -37 14 -40 -3 -5 -26 22 -62 74 -17 25 -41 60
|
||||
-52 76 -21 30 -118 181 -134 208 -4 8 -21 35 -36 61 -37 60 -133 247 -170 329
|
||||
-54 123 -107 261 -131 345 -7 23 -10 29 -16 29 -9 0 -10 -3 -16 -68 -16 -187 -15
|
||||
-298 4 -473 14 -124 47 -294 94 -485 25 -97 27 -106 83 -274 57 -173 84 -249 124
|
||||
-348 16 -41 41 -103 55 -138 14 -35 45 -105 68 -156 24 -51 49 -106 57 -124 39
|
||||
-87 106 -217 205 -398 25 -45 53 -98 63 -117 11 -19 21 -36 23 -37 7 -4 221 -3
|
||||
225 1 2 3 6 20 7 40 2 19 6 44 8 55 2 11 5 31 6 44 1 13 6 49 10 80 4 31 12 83
|
||||
16 116 18 132 52 331 112 658 4 23 13 73 20 110 19 101 46 237 66 330 10 45 22
|
||||
99 26 120 20 91 35 161 48 214 15 62 33 136 40 162 4 14 21 79 37 144 17 65 35
|
||||
134 40 154 6 20 18 63 27 96 36 134 103 348 152 486 14 39 32 90 40 114 9 24 23
|
||||
65 32 90 9 25 24 66 32 90 8 24 24 65 34 90 10 25 28 72 40 104 26 70 43 112 119
|
||||
292 9 21 20 48 25 60 5 12 20 46 33 76 157 352 355 737 536 1042 76 127 135 224
|
||||
149 246 8 12 22 36 32 52 123 215 410 628 606 872 180 225 306 368 466 528 173
|
||||
173 333 313 513 449 72 55 91 67 91 58 0 -6 -8 -14 -123 -135 -81 -86 -168 -183
|
||||
-283 -316 -135 -156 -335 -426 -496 -668 -81 -123 -202 -319 -270 -441 -21 -36
|
||||
-52 -91 -70 -123 -52 -91 -166 -313 -209 -408 -22 -47 -48 -101 -57 -120 -9 -19
|
||||
-25 -54 -36 -78 -17 -39 -35 -79 -99 -224 -9 -20 -23 -54 -32 -76 -23 -55 -65
|
||||
-155 -87 -208 -19 -46 -48 -118 -80 -202 -10 -27 -27 -70 -36 -94 -10 -24 -29
|
||||
-76 -44 -116 -15 -40 -30 -81 -34 -92 -4 -11 -15 -42 -24 -68 -9 -26 -19 -54 -22
|
||||
-62 -6 -15 -26 -74 -50 -144 -8 -25 -23 -69 -33 -98 -30 -86 -124 -379 -151 -472
|
||||
-9 -31 -26 -87 -38 -124 -12 -37 -27 -89 -34 -114 -7 -25 -19 -66 -26 -90 -7 -24
|
||||
-17 -59 -22 -78 -9 -34 -28 -101 -47 -164 -5 -19 -15 -54 -21 -78 -13 -51 -28
|
||||
-105 -48 -180 -5 -18 -16 -59 -24 -92 -8 -33 -21 -81 -28 -106 -24 -87 -61 -229
|
||||
-92 -358 -11 -44 -25 -98 -30 -120 -21 -80 -86 -345 -94 -380 -12 -52 -17 -75
|
||||
-38 -158 -21 -87 -35 -147 -44 -186 -15 -67 -47 -211 -54 -244 -5 -20 -12 -50
|
||||
-16 -68 -24 -105 -33 -145 -42 -188 -6 -26 -13 -60 -16 -74 -9 -41 -19 -101 -20
|
||||
-116 0 -14 0 -14 58 -43 32 -16 61 -29 65 -29 4 0 23 24 52 67 55 80 95 133 200
|
||||
257 108 129 221 243 347 350 20 18 47 41 60 51 12 11 45 37 74 59 29 22 65 50 80
|
||||
61 34 26 135 97 182 128 19 12 58 38 88 57 30 19 63 41 74 48 11 7 39 25 62 39
|
||||
77 49 110 70 192 127 45 31 89 61 96 66 22 15 121 95 164 132 57 50 156 149 208
|
||||
209 57 64 151 193 193 263 31 51 38 68 34 73 -2 1 -43 -18 -92 -42 -49 -24 -100
|
||||
-48 -113 -54 -79 -34 -112 -48 -148 -60 -86 -31 -283 -95 -338 -111 -32 -8 -91
|
||||
-25 -132 -37 -78 -21 -317 -101 -398 -133 -26 -10 -64 -24 -84 -32 -20 -7 -48
|
||||
-19 -64 -25 -26 -12 -46 -17 -46 -10 0 4 12 13 74 60 87 65 101 74 216 143 99 59
|
||||
294 161 394 205 71 32 147 67 236 111 17 8 51 24 76 35 122 54 319 160 433 235
|
||||
150 97 274 196 384 307 124 125 209 240 286 385 20 38 53 108 63 136 3 8 13 37
|
||||
24 66 31 84 61 193 72 263 6 38 6 47 -2 47 -1 0 -17 -12 -34 -26 -39 -32 -127
|
||||
-96 -175 -127 -110 -72 -280 -155 -429 -211 -42 -15 -181 -58 -260 -79 -162 -43
|
||||
-418 -79 -644 -91 -105 -5 -130 -5 -130 2 0 7 16 12 67 24 176 40 443 130 669
|
||||
225 93 39 268 127 356 178 62 36 198 123 240 155 263 195 443 402 602 694 52 97
|
||||
104 228 139 354 15 53 36 152 51 246 20 123 31 317 18 322 -8 4 -15 -1 -29 -19
|
||||
-26 -33 -92 -101 -137 -141 -107 -96 -288 -218 -431 -290 -49 -24 -149 -71 -169
|
||||
-78 -8 -3 -27 -11 -44 -18 -16 -7 -34 -14 -40 -16 -5 -2 -23 -8 -38 -14 -67 -25
|
||||
-143 -50 -206 -68 -10 -3 -46 -13 -80 -23 -63 -19 -115 -32 -186 -47 -22 -5 -55
|
||||
-12 -74 -16 -19 -4 -47 -10 -62 -14 -15 -3 -44 -8 -64 -10 -20 -3 -57 -8 -82 -12
|
||||
-63 -10 -80 -12 -80 -8 0 6 15 16 42 26 15 6 56 24 90 40 34 16 91 42 126 57 95
|
||||
42 300 147 399 205 152 88 190 110 293 178 197 129 349 246 490 379 139 129 221
|
||||
225 306 357 159 244 256 534 310 924 3 26 7 50 26 174 6 42 19 112 34 187 15 78
|
||||
53 213 83 299 31 89 67 168 120 268 100 185 247 349 415 460 53 35 163 86 230
|
||||
106 62 19 57 22 -74 46 -83 15 -107 18 -190 24 -69 5 -270 7 -340 3z M10962
|
||||
13252 c-22 -4 -51 -10 -64 -14 -13 -4 -38 -11 -56 -17 -141 -42 -381 -164 -597
|
||||
-303 -12 -8 -31 -21 -44 -29 -71 -45 -259 -181 -345 -250 -207 -164 -321 -263
|
||||
-550 -480 -94 -88 -287 -283 -354 -358 -17 -18 -70 -76 -120 -130 -94 -102 -172
|
||||
-190 -256 -287 -27 -32 -62 -72 -78 -90 -16 -18 -61 -70 -100 -116 -155 -182
|
||||
-267 -303 -416 -450 -127 -125 -183 -178 -212 -202 -13 -11 -37 -32 -54 -46 -74
|
||||
-65 -108 -89 -108 -76 0 4 31 51 70 104 14 19 37 52 52 74 15 22 54 76 87 119 53
|
||||
72 67 95 55 95 -6 0 -79 -23 -140 -45 -239 -85 -448 -207 -599 -353 -44 -41 -107
|
||||
-111 -146 -160 -57 -73 -143 -211 -203 -330 -41 -80 -115 -241 -150 -326 -8 -20
|
||||
-20 -47 -26 -60 -26 -56 -106 -258 -163 -412 -34 -92 -132 -383 -161 -476 -72
|
||||
-235 -91 -299 -115 -394 -42 -157 -53 -203 -67 -268 -6 -27 -12 -56 -14 -64 -6
|
||||
-23 -16 -77 -16 -88 0 -12 23 -34 74 -67 71 -47 136 -74 276 -115 176 -51 387
|
||||
-77 708 -86 153 -5 357 -1 476 8 26 2 76 6 110 8 139 9 375 34 504 54 21 3 57 9
|
||||
80 12 82 11 129 18 197 30 38 7 78 14 90 16 11 2 36 6 54 10 18 3 57 11 87 16 63
|
||||
12 227 46 290 61 78 17 195 45 246 58 28 6 60 14 72 17 25 6 134 36 382 104 37
|
||||
10 76 21 86 24 41 11 281 85 380 116 250 79 681 228 878 302 54 20 195 73 344
|
||||
128 48 18 107 40 131 48 24 9 47 18 51 21 5 3 21 26 37 51 16 25 53 78 81 118 87
|
||||
122 116 167 147 231 54 109 73 181 73 281 0 90 -6 109 -96 298 -78 165 -170 375
|
||||
-229 524 -37 93 -108 303 -133 394 -36 130 -85 346 -102 446 -23 143 -30 193 -34
|
||||
242 -2 29 -6 77 -8 108 -8 91 -5 301 4 414 16 192 38 322 90 528 34 136 90 308
|
||||
130 399 32 75 95 200 116 235 9 14 16 28 16 32 0 10 -27 41 -100 116 -105 107
|
||||
-159 150 -247 193 -114 58 -250 78 -371 57z m-574 -653 c0 -3 -11 -19 -24 -35
|
||||
-56 -66 -193 -249 -280 -372 -11 -16 -37 -53 -58 -82 -36 -52 -121 -175 -270
|
||||
-396 -75 -111 -134 -197 -180 -262 -11 -16 -37 -53 -56 -82 -20 -29 -50 -72 -68
|
||||
-96 -17 -24 -51 -71 -75 -104 -23 -33 -53 -73 -67 -90 -13 -16 -45 -57 -70 -90
|
||||
-25 -33 -54 -71 -66 -84 -11 -14 -36 -44 -56 -68 -211 -257 -378 -416 -712 -676
|
||||
-202 -158 -451 -314 -678 -427 -89 -44 -108 -51 -108 -43 0 6 60 71 172 186 36
|
||||
37 101 106 144 152 43 46 93 99 110 118 35 36 141 155 190 212 17 19 46 53 66 75
|
||||
106 116 265 304 348 409 22 28 50 62 62 76 23 26 112 138 204 254 45 57 121 152
|
||||
238 294 56 69 157 186 190 222 14 15 39 42 54 60 81 93 266 279 418 420 102 95
|
||||
305 256 444 353 125 86 128 88 128 76z M15278 9526 c-254 -10 -537 -42 -734 -84
|
||||
-25 -6 -76 -16 -112 -24 -36 -8 -77 -17 -90 -20 -13 -3 -40 -9 -60 -14 -53 -12
|
||||
-94 -23 -148 -39 -26 -7 -67 -19 -90 -26 -105 -29 -86 -17 -169 -106 -108 -115
|
||||
-183 -200 -183 -207 0 -3 1 -6 3 -7 2 -1 60 12 129 30 238 60 436 99 672 133 378
|
||||
53 762 62 1100 26 394 -43 669 -112 950 -237 86 -38 120 -56 199 -104 127 -76
|
||||
190 -124 288 -220 56 -55 72 -75 59 -75 -3 0 -36 11 -75 24 -104 35 -245 74 -334
|
||||
92 -333 70 -595 86 -899 56 -432 -43 -871 -187 -1332 -438 -230 -125 -518 -327
|
||||
-752 -525 -121 -103 -206 -178 -288 -255 -42 -39 -93 -86 -112 -104 -177 -163
|
||||
-532 -518 -810 -808 -46 -48 -129 -135 -184 -192 -55 -58 -148 -155 -207 -216
|
||||
-100 -105 -332 -337 -427 -428 -81 -78 -251 -228 -320 -282 -287 -228 -476 -351
|
||||
-804 -523 -150 -78 -582 -266 -840 -365 -243 -93 -485 -179 -676 -241 -74 -24
|
||||
-251 -78 -274 -85 -11 -3 -36 -10 -56 -16 -106 -31 -150 -44 -280 -80 -47 -13
|
||||
-154 -41 -238 -60 -23 -6 -56 -14 -74 -18 -49 -12 -99 -23 -152 -34 -26 -5 -77
|
||||
-16 -113 -24 -67 -15 -315 -60 -391 -72 -24 -4 -62 -10 -84 -14 -52 -10 -236 -33
|
||||
-245 -31 -14 3 25 18 115 45 164 49 282 91 490 173 160 63 238 98 416 183 31 15
|
||||
73 35 93 45 168 79 470 256 681 398 361 243 556 410 872 745 370 392 569 586 811
|
||||
794 96 82 247 200 349 274 154 112 324 220 486 308 97 53 271 133 347 160 36 13
|
||||
31 15 98 -39 67 -55 115 -85 214 -132 36 -17 125 -51 135 -51 14 0 12 8 -3 17
|
||||
-85 50 -155 106 -231 184 -78 80 -123 140 -167 230 -67 131 -93 230 -104 379 -10
|
||||
146 6 266 54 414 47 145 132 289 232 396 80 84 173 154 279 208 60 31 97 45 164
|
||||
64 90 25 155 34 256 34 89 0 130 -5 210 -22 159 -36 312 -120 437 -242 73 -71
|
||||
127 -143 178 -242 24 -45 36 -73 65 -154 9 -25 18 -35 27 -28 4 2 29 20 56 40 28
|
||||
20 52 40 55 45 5 11 -14 67 -52 154 -62 139 -127 230 -246 343 -51 49 -83 74
|
||||
-130 105 -131 85 -243 133 -370 159 -57 12 -167 24 -215 24 -43 0 -151 -9 -193
|
||||
-16 -197 -33 -383 -125 -554 -272 -110 -94 -212 -233 -282 -384 -36 -76 -52 -123
|
||||
-78 -226 -28 -109 -38 -196 -35 -317 2 -72 1 -86 -4 -91 -3 -3 -6 -6 -6 -6 -1 0
|
||||
-28 -10 -97 -35 -24 -9 -67 -24 -96 -35 -126 -45 -218 -78 -238 -86 -12 -5 -27
|
||||
-10 -32 -12 -6 -2 -36 -13 -66 -25 -78 -30 -130 -50 -214 -81 -55 -20 -147 -55
|
||||
-232 -88 -69 -26 -139 -54 -188 -72 -71 -27 -114 -43 -168 -64 -30 -12 -76 -30
|
||||
-102 -40 -80 -31 -85 -33 -194 -76 -58 -23 -124 -48 -146 -56 -22 -8 -73 -28
|
||||
-114 -44 -41 -16 -91 -36 -112 -44 -21 -8 -50 -19 -64 -25 -14 -5 -38 -14 -52
|
||||
-20 -14 -5 -41 -16 -60 -23 -19 -8 -51 -20 -72 -28 -21 -8 -49 -19 -62 -24 -13
|
||||
-5 -46 -18 -74 -28 -27 -10 -65 -25 -84 -32 -19 -7 -47 -18 -62 -24 -15 -6 -36
|
||||
-14 -46 -18 -10 -4 -31 -12 -46 -18 -15 -6 -34 -13 -42 -16 -8 -3 -25 -9 -38 -14
|
||||
-13 -5 -30 -11 -38 -14 -8 -3 -61 -23 -118 -44 -57 -22 -113 -43 -124 -47 -11 -4
|
||||
-69 -25 -128 -47 -59 -22 -118 -43 -130 -48 -18 -7 -73 -27 -198 -72 -11 -4 -35
|
||||
-13 -54 -20 -19 -7 -69 -25 -112 -41 -43 -15 -97 -34 -120 -43 -23 -8 -67 -24
|
||||
-98 -35 -31 -11 -66 -24 -78 -29 -27 -10 -65 -24 -118 -42 -22 -7 -74 -25 -116
|
||||
-40 -42 -15 -91 -32 -110 -38 -19 -6 -47 -16 -64 -22 -16 -6 -61 -21 -98 -34 -37
|
||||
-13 -113 -39 -168 -58 -55 -19 -160 -54 -234 -79 -74 -24 -162 -53 -196 -65 -66
|
||||
-23 -147 -50 -304 -101 -54 -18 -151 -50 -216 -71 -65 -21 -164 -53 -220 -71
|
||||
-224 -70 -333 -103 -388 -119 -32 -9 -81 -24 -110 -33 -59 -18 -258 -73 -362
|
||||
-101 -38 -10 -102 -27 -142 -38 -367 -99 -775 -177 -1038 -196 -98 -8 -291 -7
|
||||
-360 1 -187 23 -313 72 -405 158 -49 46 -73 87 -87 147 -10 40 -10 52 0 100 43
|
||||
209 194 467 432 740 139 159 315 325 508 480 88 71 120 95 250 191 56 41 254 173
|
||||
304 203 16 10 46 29 67 41 41 27 244 142 307 176 172 90 263 134 484 231 46 20
|
||||
129 52 202 78 88 31 137 49 145 54 5 2 18 22 29 45 31 59 73 139 100 186 24 41
|
||||
27 50 20 55 -5 3 -108 -15 -168 -30 -25 -6 -56 -13 -68 -16 -100 -22 -195 -52
|
||||
-362 -111 -217 -78 -354 -137 -565 -242 -216 -109 -445 -244 -633 -374 -169 -116
|
||||
-247 -176 -402 -305 -78 -65 -78 -64 -205 -186 -201 -190 -371 -390 -508 -594
|
||||
-62 -93 -121 -196 -178 -309 -19 -37 -23 -47 -60 -138 -39 -94 -61 -169 -81 -269
|
||||
-30 -159 -29 -288 2 -424 23 -101 47 -163 97 -254 139 -249 392 -463 778 -657 66
|
||||
-34 155 -75 203 -94 34 -13 55 -22 82 -33 35 -14 183 -68 218 -80 15 -5 43 -14
|
||||
62 -19 19 -6 52 -16 74 -23 84 -26 175 -51 248 -68 104 -25 98 -24 220 -50 306
|
||||
-65 634 -107 994 -126 100 -5 385 -11 458 -9 144 3 321 10 372 13 137 9 232 16
|
||||
288 22 232 23 225 22 460 52 169 22 440 68 672 114 33 6 70 13 82 16 32 6 187 40
|
||||
282 62 24 6 59 14 78 18 19 4 66 15 104 24 39 9 81 19 94 22 68 15 513 133 614
|
||||
163 19 6 57 17 84 25 28 8 70 21 94 28 24 7 79 23 122 36 43 12 89 26 102 30 13
|
||||
4 48 15 78 24 168 52 316 100 446 146 42 15 107 37 144 50 132 45 359 128 502
|
||||
183 61 23 141 53 178 67 37 14 91 35 118 46 28 11 73 29 100 40 99 38 343 137
|
||||
458 186 171 72 333 141 384 164 31 14 79 35 108 47 54 23 129 56 222 99 30 14 86
|
||||
39 124 56 39 17 118 54 177 82 59 27 108 50 109 50 1 0 46 22 101 48 54 27 122
|
||||
60 151 73 98 46 561 281 637 324 17 10 64 35 105 57 344 182 837 498 1136 730
|
||||
178 137 220 171 330 264 68 57 138 116 204 170 27 22 62 51 78 64 17 14 53 42 82
|
||||
64 29 22 77 59 108 82 120 91 338 241 431 297 14 9 62 38 106 65 104 64 270 158
|
||||
363 207 40 21 92 49 117 62 25 14 77 41 116 60 39 20 116 58 171 86 55 27 114 56
|
||||
130 63 17 7 65 31 109 52 43 22 97 48 120 58 73 33 280 130 304 143 15 8 24 15
|
||||
25 21 3 12 -21 90 -44 140 -22 48 -70 133 -106 188 -43 65 -110 143 -189 220
|
||||
-117 115 -171 158 -301 237 -91 55 -165 95 -220 120 -24 11 -54 24 -66 30 -71 32
|
||||
-189 73 -324 112 -54 16 -219 52 -294 65 -109 18 -272 36 -380 42 -88 4 -259 8
|
||||
-312 6z M12694 8423 c-3 -7 -6 -19 -8 -27 -5 -24 -26 -84 -47 -132 -43 -96 -79
|
||||
-152 -141 -214 -80 -81 -161 -130 -292 -178 -26 -9 -40 -16 -40 -20 0 -4 12 -10
|
||||
36 -17 161 -51 276 -136 366 -270 42 -63 97 -192 120 -281 9 -35 20 -34 28 2 10
|
||||
46 53 165 78 212 44 86 102 158 170 214 70 57 129 89 222 120 52 17 55 23 20 33
|
||||
-118 35 -241 107 -308 180 -78 84 -148 224 -177 359 -8 35 -17 42 -27 19z M5933
|
||||
7589 c-7 -5 -39 -97 -53 -154 -7 -27 -15 -59 -18 -71 -16 -59 -50 -198 -58 -230
|
||||
-4 -20 -16 -73 -26 -118 -19 -86 -24 -107 -40 -196 -29 -151 -30 -170 -18 -231
|
||||
25 -131 110 -235 254 -311 83 -44 266 -98 352 -104 32 -3 32 -3 96 23 35 14 91
|
||||
36 124 49 80 31 216 86 258 104 19 8 55 23 80 33 55 21 143 57 185 75 16 7 83 34
|
||||
148 60 64 25 122 48 127 51 9 4 70 28 144 58 17 7 61 24 98 39 37 15 97 38 132
|
||||
52 73 29 135 53 216 84 31 12 68 26 82 32 14 6 58 22 96 37 64 24 131 50 246 95
|
||||
24 10 71 28 104 40 33 12 66 25 74 28 8 3 27 10 42 16 15 6 34 13 42 16 19 8 98
|
||||
38 144 55 61 23 177 66 194 71 9 3 48 17 88 32 40 15 93 34 118 42 53 17 92 34
|
||||
92 39 0 6 -35 2 -148 -15 -21 -3 -69 -10 -106 -14 -37 -5 -91 -12 -120 -16 -223
|
||||
-33 -612 -69 -900 -84 -387 -19 -756 -12 -1049 20 -168 18 -373 60 -496 100 -165
|
||||
54 -271 105 -368 177 -43 32 -106 90 -116 108 -7 11 -11 13 -20 8z"/>
|
||||
<path fill="#4A6B5C" d="M11642 17427 c4 -332 10 -651 14 -799 7 -251 10 -586 5 -589 -2 -2 -16 2 -31 7
|
||||
-43 15 -81 19 -148 17 -47 -1 -68 -4 -100 -12 -101 -26 -179 -72 -254 -147 -67
|
||||
-67 -115 -151 -141 -248 -7 -28 -9 -45 -9 -92 0 -73 10 -119 38 -178 25 -50 48
|
||||
-79 89 -110 124 -94 277 -104 429 -29 169 84 285 250 316 453 7 45 8 269 2 534
|
||||
-2 92 -5 254 -6 359 -3 182 -3 191 4 194 4 1 11 0 17 -3 5 -3 26 -17 48 -30 117
|
||||
-70 178 -120 234 -190 68 -85 98 -225 73 -346 -13 -62 -52 -158 -78 -190 -12 -16
|
||||
-20 -29 -20 -36 0 -25 127 73 179 137 40 51 79 136 102 225 42 157 12 352 -73
|
||||
488 -71 112 -130 174 -264 274 -104 78 -211 178 -269 252 -31 40 -97 143 -124
|
||||
193 -15 26 -21 35 -28 35 -8 0 -8 -1 -5 -169z M9054 17478 c-44 -17 -84 -32 -90
|
||||
-34 -5 -2 -34 -13 -64 -24 -30 -11 -90 -34 -134 -50 -92 -34 -137 -51 -188 -71
|
||||
-32 -13 -121 -46 -204 -76 -42 -16 -95 -36 -165 -63 -35 -14 -69 -27 -77 -30 -78
|
||||
-29 -158 -59 -196 -74 -16 -6 -62 -23 -102 -38 -40 -15 -82 -30 -94 -35 -12 -4
|
||||
-28 -10 -36 -13 -8 -3 -45 -17 -82 -31 -98 -39 -154 -60 -201 -77 -62 -22 -59
|
||||
-18 -58 -86 1 -32 3 -89 5 -128 2 -38 6 -140 8 -226 2 -86 6 -186 8 -222 2 -36 6
|
||||
-125 8 -198 2 -73 7 -195 10 -272 3 -77 8 -199 10 -272 2 -73 6 -162 8 -198 6
|
||||
-101 5 -198 -1 -203 -4 -3 -11 -2 -27 3 -29 10 -89 19 -136 23 -73 4 -157 -13
|
||||
-231 -49 -132 -63 -232 -160 -302 -294 -54 -103 -78 -243 -61 -348 16 -92 49
|
||||
-158 110 -220 45 -46 94 -75 164 -98 32 -11 38 -12 106 -12 66 0 75 1 112 11 95
|
||||
25 152 54 220 111 117 99 204 229 239 360 28 104 32 152 27 330 -2 77 -6 208 -8
|
||||
292 -2 84 -6 207 -8 274 -2 67 -7 208 -10 312 -3 105 -8 237 -10 294 -8 244 -9
|
||||
300 -3 307 5 6 26 16 65 29 9 3 38 14 64 24 26 10 85 32 130 48 45 17 105 39 132
|
||||
50 28 11 82 32 122 46 90 33 112 41 134 50 27 11 86 34 118 46 15 6 34 13 42 16
|
||||
19 8 77 30 154 58 75 28 161 60 210 80 58 23 159 57 170 58 12 0 12 0 15 -34 1
|
||||
-19 2 -71 3 -116 0 -78 3 -202 12 -494 2 -73 5 -212 6 -309 3 -204 7 -188 -38
|
||||
-173 -87 27 -205 27 -295 -1 -167 -53 -300 -170 -379 -333 -64 -133 -74 -282 -26
|
||||
-407 32 -82 102 -158 182 -197 61 -30 98 -39 171 -41 50 -2 64 -1 99 7 155 33
|
||||
282 127 389 285 51 77 87 173 106 287 5 36 6 66 6 202 -1 88 -3 235 -5 326 -6
|
||||
240 -15 700 -18 860 -1 76 -4 212 -6 302 -2 90 -5 234 -6 320 -2 93 -5 158 -7
|
||||
161 -2 3 -7 5 -10 5 -4 0 -43 -14 -87 -30z M10590 16917 c-241 -7 -571 -39 -792
|
||||
-76 -20 -3 -48 -7 -62 -9 -59 -9 -147 -29 -177 -41 -54 -21 -82 -53 -117 -131
|
||||
-27 -61 -25 -67 22 -58 96 19 176 34 190 36 9 1 54 8 100 16 199 32 325 50 476
|
||||
64 33 3 76 8 96 10 166 19 676 42 990 46 76 1 78 1 79 9 3 19 -49 74 -84 89 -39
|
||||
17 -188 37 -327 44 -76 4 -295 4 -394 1z M12612 16699 c-3 -5 -3 -12 2 -32 3 -14
|
||||
7 -38 8 -54 4 -35 9 -41 45 -49 12 -3 47 -11 77 -19 30 -7 76 -19 102 -26 105
|
||||
-26 423 -127 520 -165 26 -10 57 -22 68 -26 56 -20 134 -52 186 -75 32 -14 74
|
||||
-32 94 -40 20 -7 89 -40 153 -72 64 -32 118 -57 119 -56 5 4 -39 38 -122 93 -47
|
||||
31 -100 65 -118 76 -18 11 -35 22 -38 24 -3 3 -22 13 -42 24 -20 11 -50 27 -68
|
||||
37 -53 30 -201 103 -260 128 -48 21 -131 54 -236 95 -24 10 -209 70 -260 86 -22
|
||||
6 -219 56 -224 56 0 0 -3 -2 -6 -5z M7107 16128 c-9 -5 -50 -24 -92 -43 -323
|
||||
-143 -696 -369 -1031 -626 -221 -170 -375 -306 -558 -494 -111 -115 -112 -116
|
||||
-93 -140 6 -6 22 -29 36 -51 39 -59 40 -60 50 -56 5 2 42 35 82 72 203 190 371
|
||||
336 516 449 58 45 189 143 234 174 18 12 46 32 62 44 28 21 70 49 173 115 29 18
|
||||
64 41 78 50 14 9 47 29 72 44 25 15 57 34 70 42 33 20 113 66 206 119 44 24 88
|
||||
50 98 58 11 7 35 24 53 37 18 12 41 30 51 39 18 17 19 18 22 49 3 18 4 53 3 78
|
||||
-1 52 -3 55 -32 40z M10520 15886 c-173 -8 -506 -53 -752 -102 -120 -24 -324 -75
|
||||
-333 -83 -7 -6 -7 -14 -6 -92 0 -59 2 -88 5 -92 5 -6 11 -5 75 10 317 80 557 129
|
||||
797 163 163 23 352 47 410 51 32 3 57 7 61 10 3 3 10 16 15 30 5 14 17 40 26 58
|
||||
20 36 21 44 11 46 -8 2 -265 2 -309 1z M12215 15662 c-35 -7 -64 -14 -65 -15 -7
|
||||
-7 3 -15 25 -19 13 -2 46 -10 75 -16 29 -6 64 -14 78 -16 32 -6 243 -58 314 -78
|
||||
145 -41 396 -121 476 -151 112 -43 195 -71 205 -71 7 0 20 8 36 20 28 22 32 29
|
||||
25 38 -7 9 -174 90 -254 124 -174 74 -276 111 -414 151 -86 25 -89 26 -205 37
|
||||
-147 14 -213 13 -296 -4z M4890 15080 c-8 -13 -6 -751 6 -2098 2 -253 3 -483 1
|
||||
-512 -4 -61 1 -57 -55 -38 -75 26 -179 34 -265 22 -80 -12 -190 -56 -270 -108
|
||||
-89 -56 -190 -168 -241 -264 -60 -114 -78 -180 -81 -300 -1 -61 -1 -83 5 -112 12
|
||||
-67 38 -129 81 -194 63 -95 171 -162 292 -182 214 -34 443 73 605 283 93 120 153
|
||||
267 172 421 3 25 4 228 3 690 0 360 0 742 0 850 1 108 1 200 1 205 0 6 2 9 7 9 6
|
||||
0 108 -66 205 -132 102 -70 218 -182 280 -270 132 -187 162 -383 91 -596 -31 -93
|
||||
-101 -215 -158 -275 -24 -25 -28 -35 -15 -35 10 0 96 43 123 61 94 64 168 140
|
||||
219 227 23 37 69 140 84 185 32 96 46 187 46 309 0 121 -11 186 -50 305 -33 100
|
||||
-73 178 -148 287 -55 82 -102 140 -174 216 -117 125 -256 277 -304 332 -158 184
|
||||
-324 442 -415 646 -29 63 -32 69 -38 71 -2 1 -6 -1 -7 -3z M6454 14216 c-4 -5
|
||||
-30 -27 -56 -49 -162 -137 -326 -292 -326 -310 0 -5 11 -29 25 -54 14 -25 34 -64
|
||||
44 -87 10 -23 21 -44 24 -46 5 -3 35 24 125 111 108 105 152 145 251 231 19 16
|
||||
35 33 36 37 2 4 -3 14 -12 24 -23 28 -50 71 -68 111 -19 42 -28 49 -43 32z M4552
|
||||
13379 c-42 -45 -92 -104 -122 -143 -16 -21 -44 -57 -62 -80 -51 -65 -155 -224
|
||||
-237 -360 -26 -43 -73 -127 -160 -286 -82 -152 -244 -485 -311 -640 -26 -60 -68
|
||||
-161 -76 -184 -4 -11 -11 -30 -16 -42 -8 -21 -21 -56 -61 -168 -10 -27 -34 -99
|
||||
-53 -158 -19 -59 -36 -112 -38 -118 -2 -5 -6 -20 -10 -32 -3 -12 -14 -53 -24 -90
|
||||
-27 -101 -59 -231 -78 -326 -15 -70 -48 -260 -60 -338 -50 -343 -62 -711 -32
|
||||
-1028 10 -108 22 -209 30 -254 12 -65 39 -198 46 -232 4 -19 12 -56 18 -82 12
|
||||
-55 16 -69 46 -175 11 -38 72 -221 86 -256 14 -37 36 -96 49 -127 5 -14 24 -57
|
||||
40 -94 56 -126 184 -370 194 -370 7 0 -2 30 -33 102 -39 93 -68 173 -118 332 -10
|
||||
33 -21 67 -24 76 -12 35 -34 119 -68 256 -25 104 -49 226 -74 376 -20 125 -23
|
||||
156 -36 300 -11 120 -16 255 -16 400 0 314 25 562 88 879 7 33 15 73 18 90 20
|
||||
104 73 321 115 461 32 110 105 326 145 430 5 13 15 39 22 58 7 19 18 46 25 60 12
|
||||
26 12 26 13 138 0 97 2 118 9 160 27 144 62 233 136 348 44 67 121 150 207 222
|
||||
36 30 36 30 118 174 78 136 124 212 202 336 117 185 143 228 156 267 5 14 5 21 0
|
||||
55 -11 66 -16 86 -23 87 -5 1 -16 -8 -31 -24z M4548 11050 c-43 -28 -87 -50 -120
|
||||
-59 -47 -14 -47 -14 -60 -34 -36 -51 -112 -312 -159 -541 -36 -173 -50 -258 -69
|
||||
-422 -19 -166 -28 -402 -20 -546 11 -213 12 -221 40 -432 9 -70 54 -300 71 -366
|
||||
50 -190 136 -430 215 -598 84 -179 112 -228 129 -228 10 0 29 16 29 24 0 3 -11
|
||||
30 -24 61 -74 171 -142 387 -200 631 -15 62 -53 270 -64 348 -41 293 -46 648 -14
|
||||
964 12 113 55 379 82 504 35 163 93 371 156 559 24 73 44 137 44 141 0 12 -11 10
|
||||
-36 -6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 908 B |