This document contains technical implementation details for v3 development.
v3/
├── cmd/tera/main.go # CLI entry point
├── internal/
│ ├── api/ # Radio Browser API client
│ ├── blocklist/ # Station blocking
│ ├── config/ # NEW: Unified configuration
│ ├── credentials/ # Secure token storage
│ ├── gist/ # GitHub Gist sync
│ ├── player/ # MPV integration
│ ├── shuffle/ # Shuffle mode manager
│ ├── storage/ # Config & favorites
│ ├── theme/ # Theme system
│ └── ui/ # Bubble Tea interface
└── pkg/utils/ # Public utilities
Config scattered across multiple files:
theme.yamlappearance_config.yamlconnection_config.yamlshuffle.yaml
Note: blocklist.json, voted_stations.json, and favorites/*.json are user data, not configuration. They should remain separate from system config.
New structure:
// v3/internal/config/config.go
package config
type Config struct {
Version string `yaml:"version"`
Player PlayerConfig `yaml:"player"`
UI UIConfig `yaml:"ui"`
Network NetworkConfig `yaml:"network"`
Shuffle ShuffleConfig `yaml:"shuffle"`
}
type PlayerConfig struct {
DefaultVolume int `yaml:"default_volume"`
BufferSizeMB int `yaml:"buffer_size_mb"`
}
type UIConfig struct {
Theme ThemeConfig `yaml:"theme"`
Appearance AppearanceConfig `yaml:"appearance"`
DefaultList string `yaml:"default_list"`
}
type ThemeConfig struct {
Name string `yaml:"name"`
Colors map[string]string `yaml:"colors"`
Padding PaddingConfig `yaml:"padding"`
}
type AppearanceConfig struct {
HeaderMode string `yaml:"header_mode"` // text, ascii, none
HeaderAlign string `yaml:"header_align"` // left, center, right
HeaderWidth int `yaml:"header_width"`
}
type NetworkConfig struct {
AutoReconnect bool `yaml:"auto_reconnect"`
ReconnectDelay int `yaml:"reconnect_delay"`
BufferSizeMB int `yaml:"buffer_size_mb"`
}
type ShuffleConfig struct {
AutoAdvance bool `yaml:"auto_advance"`
IntervalMinutes int `yaml:"interval_minutes"`
RememberHistory bool `yaml:"remember_history"`
MaxHistory int `yaml:"max_history"`
}New file structure:
# config.yaml
version: "3.0"
player:
default_volume: 80
buffer_size_mb: 50
ui:
theme:
name: "default"
colors:
primary: "#00FFFF"
highlight: "#FFFF00"
padding:
list_item_left: 2
appearance:
header_mode: "text"
header_align: "center"
header_width: 60
default_list: "My-favorites"
network:
auto_reconnect: true
reconnect_delay: 5
buffer_size_mb: 50
shuffle:
auto_advance: true
interval_minutes: 5
remember_history: true
max_history: 71: Config package
- Create
v3/internal/config/package - Define
Configstruct hierarchy - Implement
Load()andSave()functions - Write unit tests
2: Migration logic
// v3/internal/config/migrate.go
package config
func MigrateFromV2(v2ConfigDir string) (*Config, error) {
cfg := DefaultConfig()
// Read old theme.yaml
if theme, err := readV2Theme(v2ConfigDir); err == nil {
cfg.UI.Theme = theme
}
// Read old appearance_config.yaml
if appearance, err := readV2Appearance(v2ConfigDir); err == nil {
cfg.UI.Appearance = appearance
}
// Read old connection_config.yaml
if network, err := readV2Connection(v2ConfigDir); err == nil {
cfg.Network = network
}
// Read old shuffle.yaml
if shuffle, err := readV2Shuffle(v2ConfigDir); err == nil {
cfg.Shuffle = shuffle
}
// Backup old files
backupV2Configs(v2ConfigDir)
return cfg, nil
}3: Integration
- Update
internal/storage/config.goto use new config package - Update all packages that read config (theme, player, ui)
- Add migration check in
cmd/tera/main.go
4: Testing & Release
- Test migration on all platforms
- Update documentation
- Tag v3.0.0
New files:
v3/internal/config/config.gov3/internal/config/loader.gov3/internal/config/migrate.gov3/internal/config/config_test.gov3/internal/config/migrate_test.go
Modified files:
v3/internal/storage/config.go(use new config)v3/internal/theme/theme.go(read from unified config)v3/internal/ui/app.go(check migration on startup)v3/cmd/tera/main.go(auto-migrate check)
System Configuration (config.yaml):
- How the application behaves
- Default settings
- Theme and appearance preferences
- Network settings
User Data (separate files):
- What the user has done
- User-created content
- Usage history
- Caching and state
~/.config/tera/ # Config directory (os.UserConfigDir())
├── config.yaml # Unified system configuration
└── data/ # User data directory
├── blocklist.json # User-blocked stations
├── voted_stations.json # User voting history
├── favorites/ # User playlists
│ ├── Blues.json
│ ├── Jazz.json
│ └── My-favorites.json
└── cache/ # Temporary data
├── gist_metadata.json
└── search-history.json
Migration should be done automatically on first run.
// v3/cmd/tera/main.go
func main() {
// 1. Detect if migration is needed
configPath := filepath.Join(os.UserConfigDir(), "tera", "config.yaml")
if !fileExists(configPath) {
// No v3 config exists - check for v2 config
v2ConfigDir := filepath.Join(os.UserConfigDir(), "tera")
if hasV2Config(v2ConfigDir) {
// Auto-migrate with user notification
fmt.Println("🔄 Migrating from Tera v2 to v3...")
if err := migrateFromV2(v2ConfigDir); err != nil {
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
fmt.Println("Your v2 config has been backed up.")
fmt.Println("Please report this issue: https://github.com/...")
os.Exit(1)
}
fmt.Println("✓ Migration complete!")
fmt.Println(" - Config unified → ~/.config/tera/config.yaml")
fmt.Println(" - User data → ~/.config/tera/data/")
fmt.Println(" - GitHub token → OS keychain")
fmt.Println("")
}
}
// 2. Continue normal startup
app.Run()
}
func migrateFromV2(v2ConfigDir string) error {
// Create backup first
backupDir := v2ConfigDir + ".v2-backup-" + time.Now().Format("20060102-150405")
if err := copyDir(v2ConfigDir, backupDir); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
// Migrate config files
cfg, err := config.MigrateFromV2(v2ConfigDir)
if err != nil {
return fmt.Errorf("config migration failed: %w", err)
}
// Migrate user data
if err := storage.MigrateDataFromV2(v2ConfigDir); err != nil {
return fmt.Errorf("data migration failed: %w", err)
}
// Migrate GitHub token to keychain
if err := credentials.MigrateFromFile(v2ConfigDir); err != nil {
// Non-fatal - user can set token later in Settings
fmt.Printf("⚠️ Could not migrate GitHub token: %v\n", err)
fmt.Println(" You can set it later in Settings > GitHub Token")
}
// Save new config
if err := cfg.Save(); err != nil {
return fmt.Errorf("save config failed: %w", err)
}
// Clean up old config files (optional - keep backup)
// removeOldV2Files(v2ConfigDir)
return nil
}// v3/internal/storage/migrate.go
func MigrateDataFromV2(v2ConfigDir string) error {
v3DataDir := filepath.Join(os.UserConfigDir(), "tera", "data")
// Migrate user data (not config)
filesToMove := map[string]string{
"blocklist.json": "blocklist.json",
"voted_stations.json": "voted_stations.json",
"favorites": "favorites",
"gist_metadata.json": "cache/gist_metadata.json",
}
for oldFile, newFile := range filesToMove {
oldPath := filepath.Join(v2ConfigDir, oldFile)
newPath := filepath.Join(v3DataDir, newFile)
if err := moveIfExists(oldPath, newPath); err != nil {
return err
}
}
return nil
}# Hidden command for testing/debugging
tera debug migrate-check
# Output:
# V2 Config detected:
# ✓ theme.yaml
# ✓ appearance_config.yaml
# ✓ shuffle.yaml
# ✓ blocklist.json (37 stations)
# ✓ favorites/ (5 playlists)
# ✓ tokens/github_token
#
# Migration would:
# - Unified config → config.yaml
# - Move user data → data/
# - Migrate token → keychaintokens/github_token stored as plain text file - insecure and platform-specific.
Use platform-native secure storage:
- macOS: Keychain
- Linux: Secret Service (gnome-keyring, KWallet)
- Windows: Credential Manager
Add dependency:
go get github.com/zalando/go-keyringNew package:
// v3/internal/credentials/credentials.go
package credentials
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/zalando/go-keyring"
)
const (
serviceName = "tera"
tokenKey = "github_token"
)
// SetGitHubToken stores the GitHub token securely
func SetGitHubToken(token string) error {
return keyring.Set(serviceName, tokenKey, token)
}
// GetGitHubToken retrieves the GitHub token
// Falls back to TERA_GITHUB_TOKEN env var for headless environments
func GetGitHubToken() (string, error) {
// Try OS keychain first
token, err := keyring.Get(serviceName, tokenKey)
if err == nil {
return token, nil
}
// Fallback to environment variable (for CI/CD, headless servers)
if envToken := os.Getenv("TERA_GITHUB_TOKEN"); envToken != "" {
return envToken, nil
}
if err == keyring.ErrNotFound {
return "", fmt.Errorf("github token not configured. Run: tera config set-token")
}
return "", fmt.Errorf("failed to retrieve github token: %w", err)
}
// DeleteGitHubToken removes the GitHub token
func DeleteGitHubToken() error {
return keyring.Delete(serviceName, tokenKey)
}
// MigrateFromFile migrates token from v2 file storage to keychain
func MigrateFromFile(v2ConfigDir string) error {
oldPath := filepath.Join(v2ConfigDir, "tokens", "github_token")
data, err := os.ReadFile(oldPath)
if err != nil {
if os.IsNotExist(err) {
return nil // No token to migrate
}
return fmt.Errorf("failed to read old token: %w", err)
}
token := strings.TrimSpace(string(data))
if token == "" {
return nil
}
// Store in keychain
if err := SetGitHubToken(token); err != nil {
return fmt.Errorf("failed to store token in keychain: %w", err)
}
// Remove old file
os.Remove(oldPath)
// Remove tokens/ directory if empty
tokensDir := filepath.Dir(oldPath)
if isEmpty, _ := isDirEmpty(tokensDir); isEmpty {
os.Remove(tokensDir)
}
fmt.Println("✓ Migrated GitHub token to secure storage")
return nil
}
func isDirEmpty(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
return len(entries) == 0, nil
}Settings UI Integration:
// v3/internal/ui/settings.go
package ui
type TokenSettingsModel struct {
tokenInput textinput.Model
mode string // "view", "edit", "confirm"
showToken bool
currentToken string
errorMessage string
successMessage string
}
func (m *TokenSettingsModel) View() string {
switch m.mode {
case "view":
return m.viewMode()
case "edit":
return m.editMode()
case "confirm":
return m.confirmMode()
}
return ""
}
func (m *TokenSettingsModel) viewMode() string {
var token string
if m.showToken {
token = m.currentToken
} else {
token = strings.Repeat("•", min(len(m.currentToken), 20))
}
status := "❌ No token configured"
if m.currentToken != "" {
status = "✓ Token configured"
}
return fmt.Sprintf(`
Settings > GitHub Token
GitHub Token: %s [%s]
Current Status: %s
Commands:
e: Edit token
d: Delete token
s: Show/Hide token
Esc: Back to Settings
`,
token,
ternary(m.showToken, "Hide", "Show"),
status,
)
}
func (m *TokenSettingsModel) editMode() string {
return fmt.Sprintf(`
Settings > GitHub Token > Edit
Enter GitHub Token:
%s
Commands:
Enter: Save token
Ctrl+U: Clear input
Esc: Cancel
`,
m.tokenInput.View(),
)
}
func (m *TokenSettingsModel) confirmMode() string {
return fmt.Sprintf(`
Confirm Token
Token: %s
Save this token to secure storage?
y: Yes, save token
n: No, go back and edit
`,
m.tokenInput.Value(),
)
}
func (m *TokenSettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch m.mode {
case "view":
return m.handleViewKeys(msg)
case "edit":
return m.handleEditKeys(msg)
case "confirm":
return m.handleConfirmKeys(msg)
}
}
return m, nil
}
func (m *TokenSettingsModel) handleViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "e":
m.mode = "edit"
m.tokenInput.SetValue(m.currentToken)
m.tokenInput.Focus()
case "d":
return m, m.deleteToken()
case "s":
m.showToken = !m.showToken
case "esc":
return m, navigateToSettings
}
return m, nil
}
func (m *TokenSettingsModel) handleEditKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
m.mode = "confirm"
return m, nil
case "ctrl+u":
m.tokenInput.SetValue("")
case "esc":
m.mode = "view"
return m, nil
}
var cmd tea.Cmd
m.tokenInput, cmd = m.tokenInput.Update(msg)
return m, cmd
}
func (m *TokenSettingsModel) handleConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "y":
return m, m.saveToken()
case "n":
m.mode = "edit"
return m, nil
}
return m, nil
}
func (m *TokenSettingsModel) saveToken() tea.Cmd {
return func() tea.Msg {
token := m.tokenInput.Value()
if err := credentials.SetGitHubToken(token); err != nil {
return tokenErrorMsg{err: err}
}
return tokenSuccessMsg{message: "✓ GitHub token saved securely"}
}
}
func (m *TokenSettingsModel) deleteToken() tea.Cmd {
return func() tea.Msg {
if err := credentials.DeleteGitHubToken(); err != nil {
return tokenErrorMsg{err: err}
}
return tokenSuccessMsg{message: "✓ GitHub token deleted"}
}
}User Experience:
-
Interactive Users (TUI):
- Navigate to Settings > GitHub Token
- Press
eto edit - Enter token (visible by default, can verify correctness)
- Press
Enterto proceed to confirmation - Press
yto save to OS keychain - Token is automatically saved to secure storage
-
Headless Environments (CI/CD, servers):
export TERA_GITHUB_TOKEN=ghp_xxxxx tera sync
✅ More secure than plain text files
✅ Cross-platform (macOS/Linux/Windows using os.UserConfigDir() principle)
✅ Standard practice (same as browsers, Docker, Git)
✅ No tokens/ directory needed
✅ Automatic encryption by OS
✅ Environment variable fallback for headless systems
v3.0.0:
- Auto-migrate token from
tokens/github_tokento keychain on first run - Keep reading from file as fallback (deprecated, warning shown)
- Document new token management via Settings UI
v3.1.0:
- Remove file fallback completely
- Only support keychain + environment variable
// v3/internal/api/models.go
type Station struct {
// Existing fields...
StationUUID string
Name string
URLResolved string
// NEW: Play statistics
PlayCount int `json:"play_count,omitempty"`
LastPlayed time.Time `json:"last_played,omitempty"`
FirstPlayed time.Time `json:"first_played,omitempty"`
}Favorites files remain JSON, just with extra fields:
{
"stations": [
{
"stationuuid": "abc123",
"name": "Jazz FM",
"play_count": 42,
"last_played": "2026-05-15T14:30:00Z",
"first_played": "2026-03-01T09:15:00Z"
}
]
}New menu item:
1. Play from Favorites
2. Most Played ← NEW
3. Recently Played ← NEW
4. Search Stations
Implementation:
// v3/internal/ui/statistics.go
func (m *Model) showMostPlayed() tea.Cmd {
stations := m.storage.GetAllStations()
sort.Slice(stations, func(i, j int) bool {
return stations[i].PlayCount > stations[j].PlayCount
})
return m.showStationList(stations[:10], "Top 10 Most Played")
}Modified files:
v3/internal/api/models.go(add new fields)v3/internal/storage/favorites.go(update on play)v3/internal/ui/menu.go(new menu items)
New files:
v3/internal/ui/statistics.go
type Station struct {
// Existing fields...
// NEW: User rating (1-5 stars, nil = not rated)
UserRating *int `json:"user_rating,omitempty"`
}
// Helper methods
func (s *Station) SetRating(stars int) error {
if stars < 1 || stars > 5 {
return errors.New("rating must be 1-5")
}
s.UserRating = &stars
return nil
}
func (s *Station) GetRating() int {
if s.UserRating == nil {
return 0 // Not rated
}
return *s.UserRating
}While playing:
🎵 Now Playing
Station: Jazz FM
Rating: ★★★★☆ (4/5) ← Show current rating
Press 1-5 to rate this station
Press 0 to clear rating
In lists:
Favorites:
1. Jazz FM ★★★★★
2. BBC Radio ★★★★☆
3. KEXP ★★★☆☆
4. Classical FM (not rated)
type Station struct {
// Existing fields...
// NEW: User-defined tags
CustomTags []string `json:"custom_tags,omitempty"`
}{
"stationuuid": "abc123",
"name": "Jazz FM",
"custom_tags": ["workout", "coding", "focus"]
}Tag management:
Station: Jazz FM
Tags: #workout #coding #focus
Commands:
t: Add tag
d: Remove tag
f: Filter by tag
v4/
├── tera.go # Public API entry point
├── client.go # Main client
├── search.go # Search functions
├── favorites.go # Favorites management
├── player.go # Playback control
├── errors.go # Public error types
├── options.go # Client options
├── internal/ # Private implementation
│ ├── ui/ # CLI interface
│ ├── api/
│ └── storage/
└── cmd/
└── tera/
└── main.go # CLI that uses public API
// v4/tera.go
package tera
// Client is the main TERA client
type Client struct {
cfg *Config
api *api.Client
player *player.Player
store *storage.Storage
}
// New creates a new TERA client
func New(opts ...Option) (*Client, error)
// Search searches for radio stations
func (c *Client) Search(ctx context.Context, query SearchQuery) ([]Station, error)
// Play plays a radio station
func (c *Client) Play(station Station) error
// Stop stops playback
func (c *Client) Stop() error
// Favorites returns the favorites manager
func (c *Client) Favorites() *FavoritesManager
// Close cleans up resources
func (c *Client) Close() error// v4/options.go
type Option func(*Client) error
func WithConfigDir(dir string) Option {
return func(c *Client) error {
c.cfg.ConfigDir = dir
return nil
}
}
func WithVolume(vol int) Option {
return func(c *Client) error {
c.player.SetVolume(vol)
return nil
}
}
// Usage:
client, err := tera.New(
tera.WithConfigDir("/custom/path"),
tera.WithVolume(80),
)- All packages should have
_test.gofiles - Aim for >70% coverage
- Use table-driven tests
- Test config migration end-to-end
- Test player integration with mpv
- Test API integration with Radio Browser
- Install on Linux
- Install on macOS (Intel)
- Install on macOS (ARM)
- Install on Windows
- Test migration from v2
- Test fresh install
- Test all menu options
- Test playback
- Lazy load config (only when needed)
- Cache parsed config in memory
- Validate on load, not on every access
- Update play count async (don't block playback)
- Batch writes to disk (every 5 minutes or on exit)
- Index by station UUID for fast lookups
- Make all operations context-aware
- Support graceful cancellation
- Thread-safe operations
Current dependencies (keep minimal):
github.com/charmbracelet/bubbletea- TUI frameworkgithub.com/charmbracelet/bubbles- TUI componentsgithub.com/charmbracelet/lipgloss- Stylinggopkg.in/yaml.v3- YAML parsinggolang.org/x/text- Text processing
New dependencies for v3:
github.com/zalando/go-keyring- Secure credential storage
Consider adding for v4:
golang.org/x/sync/errgroup- Concurrent operationsgithub.com/stretchr/testify- Testing utilities
Each release should include:
- Updated README.md
- CHANGELOG.md entry
- Migration guide (if breaking changes)
- API documentation (for v4+)
Last Updated: February 2026
In Development: v3.0.0 (Unified Config + Secure Credentials)