feat: configurable bufo exclude/include patterns via env vars (#491)

adds BUFO_EXCLUDE_PATTERNS and BUFO_INCLUDE_PATTERNS env vars to control
which bufo images appear in the easter egg animation.

- exclude: regex patterns to filter out (default: ^bigbufo_)
- include: allowlist that overrides exclude (default: bigbufo_0_0, bigbufo_2_1)

adds CommaSeparatedStringSet type for parsing comma-delimited env vars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 8d7a5c5a bf4eeabd

Changed files
+83 -2
backend
src
backend
frontend
src
+60 -1
backend/src/backend/config.py
··· 2 2 3 3 from __future__ import annotations 4 4 5 + from functools import partial 5 6 from pathlib import Path 7 + from typing import Annotated, Any, TypeVar 6 8 from urllib.parse import urlparse 7 9 8 - from pydantic import Field, computed_field 10 + from pydantic import BeforeValidator, Field, TypeAdapter, computed_field 9 11 from pydantic_settings import BaseSettings, SettingsConfigDict 10 12 11 13 BASE_DIR = Path(__file__).resolve().parents[2] 14 + 15 + T = TypeVar("T") 16 + 17 + 18 + def validate_set_T_from_delim_string( 19 + value: str | T | set[T] | None, type_: Any, delim: str | None = None 20 + ) -> set[T]: 21 + """Parse comma-delimited string into a set. 22 + 23 + e.g. `BUFO_EXCLUDE_PATTERNS=bigbufo*,other*` -> `{"bigbufo*", "other*"}` 24 + """ 25 + if not value: 26 + return set() 27 + 28 + adapter = TypeAdapter(type_) 29 + delim = delim or "," 30 + if isinstance(value, str): 31 + return {adapter.validate_strings(s.strip()) for s in value.split(delim)} 32 + try: 33 + return {adapter.validate_python(value)} 34 + except Exception: 35 + pass 36 + try: 37 + return TypeAdapter(set[type_]).validate_python(value) 38 + except Exception: 39 + pass 40 + raise ValueError(f"invalid set[{type_}]: {value}") 41 + 42 + 43 + CommaSeparatedStringSet = Annotated[ 44 + str | set[str], 45 + BeforeValidator(partial(validate_set_T_from_delim_string, type_=str)), 46 + ] 12 47 13 48 14 49 class AppSettingsSection(BaseSettings): ··· 407 442 ) 408 443 409 444 445 + class BufoSettings(AppSettingsSection): 446 + """Bufo easter egg configuration.""" 447 + 448 + model_config = SettingsConfigDict( 449 + env_prefix="BUFO_", 450 + env_file=".env", 451 + case_sensitive=False, 452 + extra="ignore", 453 + ) 454 + 455 + exclude_patterns: CommaSeparatedStringSet = Field( 456 + default={"^bigbufo_"}, 457 + description="Regex patterns for bufo names to exclude from the easter egg animation", 458 + ) 459 + include_patterns: CommaSeparatedStringSet = Field( 460 + default={"bigbufo_0_0", "bigbufo_2_1"}, 461 + description="Regex patterns to override exclusions (allowlist)", 462 + ) 463 + 464 + 410 465 class ObservabilitySettings(AppSettingsSection): 411 466 """Observability configuration.""" 412 467 ··· 543 598 teal: TealSettings = Field( 544 599 default_factory=TealSettings, 545 600 description="teal.fm scrobbling integration settings", 601 + ) 602 + bufo: BufoSettings = Field( 603 + default_factory=BufoSettings, 604 + description="bufo easter egg settings", 546 605 ) 547 606 548 607
+2
backend/src/backend/main.py
··· 223 223 "max_upload_size_mb": settings.storage.max_upload_size_mb, 224 224 "max_image_size_mb": 20, # hardcoded limit for cover art 225 225 "default_hidden_tags": DEFAULT_HIDDEN_TAGS, 226 + "bufo_exclude_patterns": list(settings.bufo.exclude_patterns), 227 + "bufo_include_patterns": list(settings.bufo.include_patterns), 226 228 } 227 229 228 230
+19 -1
frontend/src/lib/components/BufoEasterEgg.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 + import { getServerConfig } from '$lib/config'; 3 4 4 5 interface Props { 5 6 query: string; 7 + excludePatterns?: string[]; 8 + includePatterns?: string[]; 6 9 } 7 10 8 11 interface Bufo { ··· 19 22 animationClass: string; 20 23 } 21 24 22 - let { query }: Props = $props(); 25 + let { query, excludePatterns = [], includePatterns = [] }: Props = $props(); 23 26 24 27 let bufos = $state<Bufo[]>([]); 25 28 let spawnedBufos = $state<SpawnedBufo[]>([]); ··· 63 66 } 64 67 65 68 try { 69 + // get patterns from props or config 70 + let exclude = excludePatterns; 71 + let include = includePatterns; 72 + if (exclude.length === 0 && include.length === 0) { 73 + const config = await getServerConfig(); 74 + exclude = config.bufo_exclude_patterns ?? []; 75 + include = config.bufo_include_patterns ?? []; 76 + } 77 + 66 78 const params = new URLSearchParams({ 67 79 query, 68 80 top_k: '10', 69 81 family_friendly: 'true' 70 82 }); 83 + if (exclude.length > 0) { 84 + params.set('exclude', exclude.join(',')); 85 + } 86 + if (include.length > 0) { 87 + params.set('include', include.join(',')); 88 + } 71 89 const response = await fetch(`https://find-bufo.fly.dev/api/search?${params}`); 72 90 if (response.ok) { 73 91 const data = await response.json();
+2
frontend/src/lib/config.ts
··· 6 6 max_upload_size_mb: number; 7 7 max_image_size_mb: number; 8 8 default_hidden_tags: string[]; 9 + bufo_exclude_patterns: string[]; 10 + bufo_include_patterns: string[]; 9 11 } 10 12 11 13 let serverConfig: ServerConfig | null = null;