diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..73a1135 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto + +go.sum -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da2a1e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install ALSA dev libraries + run: sudo apt-get update && sudo apt-get install -y libasound2-dev + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install ALSA dev libraries + run: sudo apt-get update && sudo apt-get install -y libasound2-dev + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + goreleaser-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Check GoReleaser config + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..749e847 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29fe8e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +### Build ### +/somafm +/somafm-* +/out/ +/dist/ + +### Go ### +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +go.work.sum +vendor/ + +### Secrets ### +.env +.idea/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..1b28df5 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,137 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +project_name: somafm + +before: + hooks: + - go mod tidy + +builds: + - id: somafm + main: ./cmd/somafm + binary: somafm + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X github.com/glebovdev/somafm-cli/internal/config.AppVersion={{.Version}} + ignore: + - goos: windows + goarch: arm64 + # Linux requires CGO for audio (oto/v3) + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + +archives: + - id: default + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "Merge pull request" + - "Merge branch" + +release: + github: + owner: glebovdev + name: somafm-cli + draft: false + prerelease: auto + name_template: "v{{.Version}}" + header: | + ## SomaFM CLI v{{.Version}} + + A terminal-based music player for SomaFM radio stations. + footer: | + --- + {{- if .PreviousTag }} + + **Full Changelog**: https://github.com/glebovdev/somafm-cli/compare/{{ .PreviousTag }}...{{ .Tag }} + {{- end }} + + ## Installation + + ### macOS + ```bash + brew install glebovdev/tap/somafm + ``` + + ### Windows + ```powershell + scoop bucket add glebovdev https://github.com/glebovdev/scoop-bucket + scoop install somafm + ``` + + ### Linux + ```bash + go install github.com/glebovdev/somafm-cli/cmd/somafm@latest + ``` + Pre-built binaries require CGO. Use `go install` which builds with your system's audio libraries. + +# Homebrew cask configuration (replaces deprecated brews) +scoops: + - name: somafm + repository: + owner: glebovdev + name: scoop-bucket + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + homepage: "https://github.com/glebovdev/somafm-cli" + description: "Terminal-based music player for SomaFM radio stations" + license: MIT + +homebrew_casks: + - name: somafm + repository: + owner: glebovdev + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Casks + url: + verified: github.com/glebovdev/somafm-cli + homepage: "https://github.com/glebovdev/somafm-cli" + description: "Terminal-based music player for SomaFM radio stations" + license: "MIT" + commit_author: + name: goreleaserbot + email: bot@goreleaser.com + binaries: + - somafm + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/somafm"] + end diff --git a/LICENSE b/LICENSE index e69de29..c87ce14 100644 --- a/LICENSE +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ilya Glebov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16b5f67 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +BINARY_NAME=somafm +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev") +LDFLAGS=-ldflags "-X github.com/glebovdev/somafm-cli/internal/config.AppVersion=$(VERSION)" + +build: + go build $(LDFLAGS) -o $(BINARY_NAME) cmd/somafm/main.go + +# Linux cross-compilation requires CGO for ALSA audio. Use GitHub Actions for Linux builds. +build-all: + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-amd64 cmd/somafm/main.go + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-arm64 cmd/somafm/main.go + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-windows-amd64.exe cmd/somafm/main.go + +test: + go test ./... + +clean: + go clean + rm -f $(BINARY_NAME) $(BINARY_NAME)-* + +.PHONY: build build-all test clean \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7fd790 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# SomaFM CLI + +[![CI](https://github.com/glebovdev/somafm-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/glebovdev/somafm-cli/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/glebovdev/somafm-cli)](https://github.com/glebovdev/somafm-cli/releases/latest) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +A terminal-based music player for [SomaFM](https://somafm.com/) radio stations built in Go. + +![Demo](demo.gif) + +## Features + +- Stream all SomaFM radio stations +- Rich terminal UI with station browser +- Volume control with visual feedback +- Pause/resume playback +- Stations sorted by listener count +- Favorites management +- Persistent configuration (volume, favorites, last station) +- Customizable color themes +- Automatic retry on stream failure + +## Installation + +### macOS + +```bash +brew install glebovdev/tap/somafm +``` + +### Windows + +```powershell +scoop bucket add glebovdev https://github.com/glebovdev/scoop-bucket +scoop install somafm +``` + +Or download `somafm_*_windows_amd64.zip` from the [Releases page](https://github.com/glebovdev/somafm-cli/releases). + +### Go Install + +```bash +go install github.com/glebovdev/somafm-cli/cmd/somafm@latest +``` + +## Usage + +```bash +somafm # Start the player +somafm --random # Start with a random station +somafm --version # Show version information +somafm --debug # Enable debug logging +somafm --help # Show help and config file path +``` + +## Keyboard Shortcuts + +| Key | Action | +|--------------------|----------------------| +| `↑` `↓` | Navigate list | +| `Enter` | Play selected station| +| `Space` | Pause / Resume | +| `<` `>` | Previous / Next station | +| `r` | Random station | +| `←` `→` or `+` `-` | Volume up / down | +| `m` | Mute / Unmute | +| `f` | Toggle favorite | +| `?` | Show help | +| `a` | About | +| `q` `Esc` | Quit | + +## Configuration + +Configuration is saved automatically to `~/.config/somafm/config.yml`. + +```yaml +volume: 70 # Volume level (0-100) +buffer_seconds: 5 # Audio buffer size (0-60 seconds) +last_station: groovesalad # Last played station ID +favorites: # List of favorite station IDs + - groovesalad + - dronezone +theme: # Color customization + background: "#1a1b25" + foreground: "#a3aacb" + borders: "#40445b" + highlight: "#ff9d65" +``` + +Settings are saved when you adjust volume, select a station, or toggle favorites. + +### Theme Options + +Colors support names (`white`, `red`, `darkcyan`), hex codes (`#ff0000`), or `default` for terminal colors. + +Available theme properties: + +| Property | Description | +|----------|-------------| +| `background` | Main background color | +| `foreground` | Text color | +| `borders` | Border color | +| `highlight` | Selection highlight | +| `muted_volume` | Volume bar color when muted | +| `header_background` | Header section background | +| `help_background` | Help panel background | +| `help_foreground` | Help panel text | +| `help_hotkey` | Hotkey highlight color | +| `modal_background` | Modal dialog background | + +## Built With + +- [tview](https://github.com/rivo/tview) - Terminal UI framework +- [tcell](https://github.com/gdamore/tcell) - Terminal cell management +- [beep](https://github.com/gopxl/beep) - Audio playback +- [resty](https://github.com/go-resty/resty) - HTTP client +- [zerolog](https://github.com/rs/zerolog) - Structured logging + +## About SomaFM + +[SomaFM](https://somafm.com/) is listener-supported, commercial-free internet radio. If you enjoy their stations, please consider [supporting them](https://somafm.com/support/). + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/cmd/somafm/main.go b/cmd/somafm/main.go new file mode 100644 index 0000000..7b7e052 --- /dev/null +++ b/cmd/somafm/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/glebovdev/somafm-cli/internal/api" + "github.com/glebovdev/somafm-cli/internal/cache" + "github.com/glebovdev/somafm-cli/internal/config" + "github.com/glebovdev/somafm-cli/internal/player" + "github.com/glebovdev/somafm-cli/internal/service" + "github.com/glebovdev/somafm-cli/internal/ui" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +var ( + versionFlag = flag.Bool("version", false, "Show version information") + debugFlag = flag.Bool("debug", false, "Enable debug logging") + randomFlag = flag.Bool("random", false, "Start with a random station") +) + +func init() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s v%s - %s\n\n", config.AppName, config.AppVersion, config.AppDescription) + fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + + configPath, err := config.GetConfigPath() + if err == nil { + if _, statErr := os.Stat(configPath); statErr == nil { + fmt.Fprintf(os.Stderr, "\nConfig file: %s\n", configPath) + } else { + fmt.Fprintf(os.Stderr, "\nConfig file will be created on first use.\n") + } + } + } +} + +func main() { + flag.Parse() + + if *versionFlag { + fmt.Printf("%s v%s\n", config.AppName, config.AppVersion) + fmt.Println(config.AppDescription) + os.Exit(0) + } + + if *debugFlag { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + + cacheDir, err := cache.GetCacheDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not get cache dir: %v\n", err) + cacheDir = os.TempDir() + } + if err := os.MkdirAll(cacheDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create log dir: %v\n", err) + } + logPath := filepath.Join(cacheDir, "debug.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not create log file: %v\n", err) + logFile = os.Stderr + } + log.Logger = log.Output(zerolog.ConsoleWriter{Out: logFile, TimeFormat: "15:04:05"}) + fmt.Printf("Debug log: %s\n", logPath) + log.Info().Msgf("Starting %s v%s (debug mode)", config.AppName, config.AppVersion) + } else { + // Avoid TUI corruption by only logging errors to /dev/null + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + logFile, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0644) + if err == nil { + log.Logger = log.Output(logFile) + } + } + + cfg, err := config.Load() + if err != nil { + log.Warn().Err(err).Msg("Failed to load config, using defaults") + cfg = config.DefaultConfig() + } + + if *debugFlag { + if configPath, err := config.GetConfigPath(); err == nil { + log.Debug().Msgf("Config: %s", configPath) + } + if cacheDir, err := cache.GetCacheDir(); err == nil { + log.Debug().Msgf("Cache: %s", cacheDir) + } + } + + apiClient := api.NewSomaFMClient() + stationService := service.NewStationService(apiClient) + somaPlayer := player.NewPlayer(cfg.BufferSeconds) + somaUi := ui.NewUI(somaPlayer, stationService, *randomFlag) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + uiDone := make(chan error, 1) + + go func() { + <-sigChan + if *debugFlag { + log.Info().Msg("Received shutdown signal, cleaning up...") + } + somaUi.Shutdown() + }() + + if *debugFlag { + log.Info().Msg("Starting UI...") + } + + // Run UI in a goroutine so we can handle signals properly + go func() { + uiDone <- somaUi.Run() + }() + + if err := <-uiDone; err != nil { + if *debugFlag { + log.Error().Err(err).Msg("Error running UI") + } + somaPlayer.Stop() + os.Exit(1) + } + + // Ensure player is fully stopped before exiting + somaPlayer.Stop() + if *debugFlag { + log.Info().Msg("SomaFM CLI stopped") + } +} diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..3f684e9 Binary files /dev/null and b/demo.gif differ diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..39d8184 --- /dev/null +++ b/demo.tape @@ -0,0 +1,54 @@ +# SomaFM CLI Demo +# Run: vhs demo.tape -o demo.gif + +Set Width 1180 +Set Height 870 +Set FontSize 13 +Set Padding 0 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 100ms + +# Start the app +Type "somafm" +Enter +Sleep 3s + +# Navigate and select station +Up +Sleep 400ms +Up +Sleep 400ms +Up +Sleep 400ms +Up +Sleep 400ms +Enter +Sleep 3.5s + +# Volume control +Left +Sleep 400ms +Left +Sleep 400ms +Right +Sleep 350ms +Right +Sleep 350ms +Right +Sleep 1s + +# Pause/resume +Space +Sleep 2.5s +Space +Sleep 2s + +# Mute/unmute +Type "m" +Sleep 2s +Type "m" +Sleep 1s + +# Quit +Type "q" +Sleep 500ms diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2627622 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/glebovdev/somafm-cli + +go 1.24.0 + +require ( + github.com/gdamore/tcell/v2 v2.13.5 + github.com/go-resty/resty/v2 v2.17.1 + github.com/gopxl/beep/v2 v2.1.1 + github.com/rivo/tview v0.42.0 + github.com/rs/zerolog v1.34.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/ebitengine/oto/v3 v3.4.0 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..79c0e05 --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= +github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.5 h1:YvWYCSr6gr2Ovs84dXbZLjDuOfQchhj8buOEqY52rpA= +github.com/gdamore/tcell/v2 v2.13.5/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= +github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/somafm.go b/internal/api/somafm.go new file mode 100644 index 0000000..cb26825 --- /dev/null +++ b/internal/api/somafm.go @@ -0,0 +1,103 @@ +// Package api provides the HTTP client for the SomaFM API. +package api + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/glebovdev/somafm-cli/internal/station" + "github.com/go-resty/resty/v2" +) + +const ( + baseURL = "https://api.somafm.com" + requestTimeout = 30 * time.Second +) + +// SomaFMClient is the HTTP client for interacting with the SomaFM API. +type SomaFMClient struct { + client *resty.Client +} + +// NewSomaFMClient creates a new SomaFM API client with sensible defaults. +func NewSomaFMClient() *SomaFMClient { + return &SomaFMClient{ + client: resty.New(). + SetBaseURL(baseURL). + SetTimeout(requestTimeout), + } +} + +// GetStations fetches the list of available radio stations from the SomaFM API. +func (c *SomaFMClient) GetStations() ([]station.Station, error) { + resp, err := c.client.R().Get("/channels.json") + if err != nil { + return nil, fmt.Errorf("failed to fetch stations: %w", err) + } + + if !resp.IsSuccess() { + return nil, fmt.Errorf("api returned status %d: %s", resp.StatusCode(), resp.Status()) + } + + var response struct { + Channels []station.Station `json:"channels"` + } + + if err := json.Unmarshal(resp.Body(), &response); err != nil { + return nil, fmt.Errorf("failed to parse stations response: %w", err) + } + + return response.Channels, nil +} + +type SongInfo struct { + Title string `json:"title"` + Artist string `json:"artist"` + Album string `json:"album"` + Date string `json:"date"` +} + +type SongsResponse struct { + ID string `json:"id"` + Songs []SongInfo `json:"songs"` +} + +// GetRecentSongs fetches the recent song history for a specific station. +func (c *SomaFMClient) GetRecentSongs(stationID string) (*SongsResponse, error) { + resp, err := c.client.R().Get(fmt.Sprintf("/songs/%s.json", stationID)) + if err != nil { + return nil, fmt.Errorf("failed to fetch songs for station %s: %w", stationID, err) + } + + if !resp.IsSuccess() { + return nil, fmt.Errorf("api returned status %d: %s", resp.StatusCode(), resp.Status()) + } + + var response SongsResponse + if err := json.Unmarshal(resp.Body(), &response); err != nil { + return nil, fmt.Errorf("failed to parse songs response: %w", err) + } + + return &response, nil +} + +func (c *SomaFMClient) GetCurrentTrackForStation(stationID string) (string, error) { + songs, err := c.GetRecentSongs(stationID) + if err != nil { + return "", err + } + + if len(songs.Songs) == 0 { + return "", nil + } + + song := songs.Songs[0] + if song.Artist != "" && song.Title != "" { + return fmt.Sprintf("%s - %s", song.Artist, song.Title), nil + } + if song.Title != "" { + return song.Title, nil + } + return "", nil +} diff --git a/internal/api/somafm_test.go b/internal/api/somafm_test.go new file mode 100644 index 0000000..c73bc64 --- /dev/null +++ b/internal/api/somafm_test.go @@ -0,0 +1,290 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/glebovdev/somafm-cli/internal/station" + "github.com/go-resty/resty/v2" +) + +func setupTestServer(handler http.HandlerFunc) (*httptest.Server, *SomaFMClient) { + server := httptest.NewServer(handler) + client := &SomaFMClient{ + client: resty.New().SetBaseURL(server.URL), + } + return server, client +} + +func TestGetStations(t *testing.T) { + expectedStations := []station.Station{ + { + ID: "groovesalad", + Title: "Groove Salad", + Listeners: "1000", + Playlists: []station.Playlist{ + {URL: "http://example.com/stream.pls", Format: "mp3", Quality: "highest"}, + }, + }, + { + ID: "dronezone", + Title: "Drone Zone", + Listeners: "500", + }, + } + + server, client := setupTestServer(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/channels.json" { + t.Errorf("Expected path /channels.json, got %s", r.URL.Path) + } + + response := struct { + Channels []station.Station `json:"channels"` + }{ + Channels: expectedStations, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + defer server.Close() + + stations, err := client.GetStations() + if err != nil { + t.Fatalf("GetStations() error = %v", err) + } + + if len(stations) != len(expectedStations) { + t.Fatalf("GetStations() returned %d stations, want %d", len(stations), len(expectedStations)) + } + + for i, st := range stations { + if st.ID != expectedStations[i].ID { + t.Errorf("stations[%d].ID = %q, want %q", i, st.ID, expectedStations[i].ID) + } + if st.Title != expectedStations[i].Title { + t.Errorf("stations[%d].Title = %q, want %q", i, st.Title, expectedStations[i].Title) + } + } +} + +func TestGetStationsEmptyResponse(t *testing.T) { + server, client := setupTestServer(func(w http.ResponseWriter, r *http.Request) { + response := struct { + Channels []station.Station `json:"channels"` + }{ + Channels: []station.Station{}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + defer server.Close() + + stations, err := client.GetStations() + if err != nil { + t.Fatalf("GetStations() error = %v", err) + } + + if len(stations) != 0 { + t.Errorf("GetStations() returned %d stations, want 0", len(stations)) + } +} + +func TestGetStationsInvalidJSON(t *testing.T) { + server, client := setupTestServer(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not valid json")) + }) + defer server.Close() + + _, err := client.GetStations() + if err == nil { + t.Error("GetStations() should return error for invalid JSON") + } +} + +func TestGetRecentSongs(t *testing.T) { + expectedSongs := &SongsResponse{ + ID: "groovesalad", + Songs: []SongInfo{ + {Title: "Song 1", Artist: "Artist 1", Album: "Album 1"}, + {Title: "Song 2", Artist: "Artist 2", Album: "Album 2"}, + }, + } + + server, client := setupTestServer(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/songs/groovesalad.json" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(expectedSongs) + }) + defer server.Close() + + songs, err := client.GetRecentSongs("groovesalad") + if err != nil { + t.Fatalf("GetRecentSongs() error = %v", err) + } + + if songs.ID != expectedSongs.ID { + t.Errorf("GetRecentSongs().ID = %q, want %q", songs.ID, expectedSongs.ID) + } + + if len(songs.Songs) != len(expectedSongs.Songs) { + t.Fatalf("GetRecentSongs() returned %d songs, want %d", len(songs.Songs), len(expectedSongs.Songs)) + } + + for i, song := range songs.Songs { + if song.Title != expectedSongs.Songs[i].Title { + t.Errorf("songs[%d].Title = %q, want %q", i, song.Title, expectedSongs.Songs[i].Title) + } + if song.Artist != expectedSongs.Songs[i].Artist { + t.Errorf("songs[%d].Artist = %q, want %q", i, song.Artist, expectedSongs.Songs[i].Artist) + } + } +} + +func TestGetRecentSongsEmpty(t *testing.T) { + server, client := setupTestServer(func(w http.ResponseWriter, _ *http.Request) { + response := SongsResponse{ + ID: "groovesalad", + Songs: []SongInfo{}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + defer server.Close() + + songs, err := client.GetRecentSongs("groovesalad") + if err != nil { + t.Fatalf("GetRecentSongs() error = %v", err) + } + + if len(songs.Songs) != 0 { + t.Errorf("GetRecentSongs() returned %d songs, want 0", len(songs.Songs)) + } +} + +func TestGetCurrentTrackForStation(t *testing.T) { + tests := []struct { + name string + songs []SongInfo + expected string + }{ + { + name: "artist and title", + songs: []SongInfo{ + {Artist: "The Beatles", Title: "Hey Jude"}, + }, + expected: "The Beatles - Hey Jude", + }, + { + name: "title only", + songs: []SongInfo{ + {Title: "Unknown Track"}, + }, + expected: "Unknown Track", + }, + { + name: "artist only", + songs: []SongInfo{ + {Artist: "Mystery Artist"}, + }, + expected: "", + }, + { + name: "empty songs", + songs: []SongInfo{}, + expected: "", + }, + { + name: "multiple songs returns first", + songs: []SongInfo{ + {Artist: "First", Title: "Song"}, + {Artist: "Second", Title: "Song"}, + }, + expected: "First - Song", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server, client := setupTestServer(func(w http.ResponseWriter, _ *http.Request) { + response := SongsResponse{ + ID: "teststation", + Songs: tt.songs, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }) + defer server.Close() + + result, err := client.GetCurrentTrackForStation("teststation") + if err != nil { + t.Fatalf("GetCurrentTrackForStation() error = %v", err) + } + + if result != tt.expected { + t.Errorf("GetCurrentTrackForStation() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestNewSomaFMClient(t *testing.T) { + client := NewSomaFMClient() + + if client == nil { + t.Fatal("NewSomaFMClient() returned nil") + } + + if client.client == nil { + t.Error("NewSomaFMClient() client.client is nil") + } +} + +func TestSongInfoFields(t *testing.T) { + song := SongInfo{ + Title: "Test Title", + Artist: "Test Artist", + Album: "Test Album", + Date: "2024-01-01", + } + + if song.Title != "Test Title" { + t.Errorf("SongInfo.Title = %q, want %q", song.Title, "Test Title") + } + if song.Artist != "Test Artist" { + t.Errorf("SongInfo.Artist = %q, want %q", song.Artist, "Test Artist") + } + if song.Album != "Test Album" { + t.Errorf("SongInfo.Album = %q, want %q", song.Album, "Test Album") + } + if song.Date != "2024-01-01" { + t.Errorf("SongInfo.Date = %q, want %q", song.Date, "2024-01-01") + } +} + +func TestSongsResponseFields(t *testing.T) { + response := SongsResponse{ + ID: "teststation", + Songs: []SongInfo{ + {Title: "Song 1"}, + {Title: "Song 2"}, + }, + } + + if response.ID != "teststation" { + t.Errorf("SongsResponse.ID = %q, want %q", response.ID, "teststation") + } + if len(response.Songs) != 2 { + t.Errorf("SongsResponse.Songs length = %d, want 2", len(response.Songs)) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..fac4773 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,163 @@ +// Package cache provides image caching functionality for station logos. +package cache + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "image" + "image/png" + "os" + "path/filepath" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + // DefaultExpiry is how long cached images are valid (7 days). + DefaultExpiry = 7 * 24 * time.Hour + // ImageSubdir is the subdirectory for cached images. + ImageSubdir = "images" + // AppName is used for the cache directory name. + AppName = "somafm" +) + +// Cache manages disk-based caching of station logo images. +type Cache struct { + baseDir string + expiry time.Duration +} + +// NewCache creates a new Cache instance with the default expiry. +func NewCache() (*Cache, error) { + cacheDir, err := GetCacheDir() + if err != nil { + return nil, err + } + + return &Cache{ + baseDir: cacheDir, + expiry: DefaultExpiry, + }, nil +} + +// GetCacheDir returns the platform-specific cache directory for the application. +func GetCacheDir() (string, error) { + userCacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("failed to get user cache directory: %w", err) + } + + cacheDir := filepath.Join(userCacheDir, AppName) + return cacheDir, nil +} + +func (c *Cache) ensureDir(dir string) error { + return os.MkdirAll(dir, 0755) +} + +func hashURL(url string) string { + hash := md5.Sum([]byte(url)) + return hex.EncodeToString(hash[:]) +} + +// GetImage retrieves a cached image by URL. Returns nil if not found or expired. +func (c *Cache) GetImage(url string) image.Image { + imageDir := filepath.Join(c.baseDir, ImageSubdir) + filename := hashURL(url) + ".png" + imagePath := filepath.Join(imageDir, filename) + + info, err := os.Stat(imagePath) + if err != nil { + return nil + } + + if time.Since(info.ModTime()) > c.expiry { + if err := os.Remove(imagePath); err != nil { + log.Debug().Err(err).Str("file", imagePath).Msg("Failed to remove expired cache file") + } + return nil + } + + file, err := os.Open(imagePath) + if err != nil { + return nil + } + defer file.Close() + + img, _, err := image.Decode(file) + if err != nil { + log.Debug().Err(err).Str("file", imagePath).Msg("Failed to decode cached image") + return nil + } + + return img +} + +// SaveImage stores an image in the cache, keyed by its URL. +func (c *Cache) SaveImage(url string, img image.Image) error { + imageDir := filepath.Join(c.baseDir, ImageSubdir) + + if err := c.ensureDir(imageDir); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + filename := hashURL(url) + ".png" + imagePath := filepath.Join(imageDir, filename) + + file, err := os.Create(imagePath) + if err != nil { + return fmt.Errorf("failed to create cache file: %w", err) + } + defer file.Close() + + if err := png.Encode(file, img); err != nil { + return fmt.Errorf("failed to encode image: %w", err) + } + + return nil +} + +// CleanExpired removes cache files older than the expiry duration. +func (c *Cache) CleanExpired() error { + imageDir := filepath.Join(c.baseDir, ImageSubdir) + + entries, err := os.ReadDir(imageDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read cache directory: %w", err) + } + + now := time.Now() + var removed, failed int + for _, entry := range entries { + if entry.IsDir() { + continue + } + + info, err := entry.Info() + if err != nil { + log.Debug().Err(err).Str("file", entry.Name()).Msg("Failed to get file info") + continue + } + + if now.Sub(info.ModTime()) > c.expiry { + filePath := filepath.Join(imageDir, entry.Name()) + if err := os.Remove(filePath); err != nil { + log.Debug().Err(err).Str("file", filePath).Msg("Failed to remove expired cache file") + failed++ + } else { + removed++ + } + } + } + + if removed > 0 || failed > 0 { + log.Debug().Int("removed", removed).Int("failed", failed).Msg("Cache cleanup completed") + } + + return nil +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..e65a10f --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,323 @@ +package cache + +import ( + "image" + "image/color" + "os" + "path/filepath" + "testing" + "time" +) + +func TestHashURL(t *testing.T) { + tests := []struct { + name string + url string + }{ + {"simple URL", "http://example.com/image.png"}, + {"URL with query params", "http://example.com/image.png?size=large"}, + {"empty string", ""}, + {"https URL", "https://somafm.com/images/logo.png"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hashURL(tt.url) + + if len(result) != 32 { + t.Errorf("hashURL(%q) length = %d, want 32", tt.url, len(result)) + } + + for _, c := range result { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("hashURL(%q) contains non-hex character: %c", tt.url, c) + } + } + }) + } +} + +func TestHashURLConsistency(t *testing.T) { + url := "http://somafm.com/images/groovesalad.png" + + hash1 := hashURL(url) + hash2 := hashURL(url) + + if hash1 != hash2 { + t.Errorf("hashURL is not consistent: %q != %q", hash1, hash2) + } +} + +func TestHashURLUniqueness(t *testing.T) { + url1 := "http://example.com/image1.png" + url2 := "http://example.com/image2.png" + + hash1 := hashURL(url1) + hash2 := hashURL(url2) + + if hash1 == hash2 { + t.Errorf("Different URLs produced same hash: %q", hash1) + } +} + +func createTestImage(width, height int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + return img +} + +func TestSaveAndGetImage(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: DefaultExpiry, + } + + testURL := "http://example.com/test-image.png" + testImg := createTestImage(100, 100) + + err := cache.SaveImage(testURL, testImg) + if err != nil { + t.Fatalf("SaveImage() error = %v", err) + } + + retrievedImg := cache.GetImage(testURL) + if retrievedImg == nil { + t.Fatal("GetImage() returned nil, expected image") + } + + bounds := retrievedImg.Bounds() + if bounds.Dx() != 100 || bounds.Dy() != 100 { + t.Errorf("Retrieved image size = %dx%d, want 100x100", bounds.Dx(), bounds.Dy()) + } +} + +func TestGetImageNonExistent(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: DefaultExpiry, + } + + result := cache.GetImage("http://example.com/nonexistent.png") + if result != nil { + t.Error("GetImage() for nonexistent URL should return nil") + } +} + +func TestGetImageExpired(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: 1 * time.Millisecond, + } + + testURL := "http://example.com/expired-image.png" + testImg := createTestImage(50, 50) + + err := cache.SaveImage(testURL, testImg) + if err != nil { + t.Fatalf("SaveImage() error = %v", err) + } + + time.Sleep(10 * time.Millisecond) + + result := cache.GetImage(testURL) + if result != nil { + t.Error("GetImage() for expired image should return nil") + } + + filename := hashURL(testURL) + ".png" + imagePath := filepath.Join(tmpDir, ImageSubdir, filename) + if _, err := os.Stat(imagePath); !os.IsNotExist(err) { + t.Error("Expired image file should have been deleted") + } +} + +func TestCleanExpired(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: 1 * time.Millisecond, + } + + testImg := createTestImage(10, 10) + urls := []string{ + "http://example.com/image1.png", + "http://example.com/image2.png", + "http://example.com/image3.png", + } + + for _, url := range urls { + if err := cache.SaveImage(url, testImg); err != nil { + t.Fatalf("SaveImage(%q) error = %v", url, err) + } + } + + time.Sleep(10 * time.Millisecond) + + err := cache.CleanExpired() + if err != nil { + t.Fatalf("CleanExpired() error = %v", err) + } + + imageDir := filepath.Join(tmpDir, ImageSubdir) + entries, err := os.ReadDir(imageDir) + if err != nil { + t.Fatalf("Failed to read image directory: %v", err) + } + + if len(entries) != 0 { + t.Errorf("CleanExpired() left %d files, want 0", len(entries)) + } +} + +func TestCleanExpiredKeepsValidFiles(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: 24 * time.Hour, + } + + testImg := createTestImage(10, 10) + testURL := "http://example.com/valid-image.png" + + if err := cache.SaveImage(testURL, testImg); err != nil { + t.Fatalf("SaveImage() error = %v", err) + } + + err := cache.CleanExpired() + if err != nil { + t.Fatalf("CleanExpired() error = %v", err) + } + + result := cache.GetImage(testURL) + if result == nil { + t.Error("CleanExpired() should not remove valid (non-expired) images") + } +} + +func TestCleanExpiredNonExistentDirectory(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: DefaultExpiry, + } + + err := cache.CleanExpired() + if err != nil { + t.Errorf("CleanExpired() should not error on non-existent directory, got %v", err) + } +} + +func TestGetCacheDir(t *testing.T) { + dir, err := GetCacheDir() + if err != nil { + t.Fatalf("GetCacheDir() error = %v", err) + } + + if dir == "" { + t.Error("GetCacheDir() returned empty string") + } + + if !filepath.IsAbs(dir) { + t.Errorf("GetCacheDir() = %q, want absolute path", dir) + } + + if filepath.Base(dir) != AppName { + t.Errorf("GetCacheDir() directory name = %q, want %q", filepath.Base(dir), AppName) + } +} + +func TestNewCache(t *testing.T) { + cache, err := NewCache() + if err != nil { + t.Fatalf("NewCache() error = %v", err) + } + + if cache == nil { + t.Fatal("NewCache() returned nil") + } + + if cache.baseDir == "" { + t.Error("NewCache() cache.baseDir is empty") + } + + if cache.expiry != DefaultExpiry { + t.Errorf("NewCache() cache.expiry = %v, want %v", cache.expiry, DefaultExpiry) + } +} + +func TestSaveImageCreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: DefaultExpiry, + } + + testURL := "http://example.com/image.png" + testImg := createTestImage(10, 10) + + err := cache.SaveImage(testURL, testImg) + if err != nil { + t.Fatalf("SaveImage() error = %v", err) + } + + imageDir := filepath.Join(tmpDir, ImageSubdir) + info, err := os.Stat(imageDir) + if err != nil { + t.Fatalf("Image directory was not created: %v", err) + } + + if !info.IsDir() { + t.Error("ImageSubdir should be a directory") + } +} + +func TestMultipleImagesSameCache(t *testing.T) { + tmpDir := t.TempDir() + + cache := &Cache{ + baseDir: tmpDir, + expiry: DefaultExpiry, + } + + images := map[string]image.Image{ + "http://example.com/image1.png": createTestImage(50, 50), + "http://example.com/image2.png": createTestImage(100, 100), + "http://example.com/image3.png": createTestImage(200, 200), + } + + for url, img := range images { + if err := cache.SaveImage(url, img); err != nil { + t.Fatalf("SaveImage(%q) error = %v", url, err) + } + } + + for url, originalImg := range images { + retrieved := cache.GetImage(url) + if retrieved == nil { + t.Errorf("GetImage(%q) returned nil", url) + continue + } + + expectedBounds := originalImg.Bounds() + retrievedBounds := retrieved.Bounds() + if retrievedBounds.Dx() != expectedBounds.Dx() || retrievedBounds.Dy() != expectedBounds.Dy() { + t.Errorf("GetImage(%q) size = %dx%d, want %dx%d", + url, retrievedBounds.Dx(), retrievedBounds.Dy(), + expectedBounds.Dx(), expectedBounds.Dy()) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2d38215 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,226 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gdamore/tcell/v2" + "gopkg.in/yaml.v3" +) + +const ( + AppName = "SomaFM CLI" + AppTagline = "Terminal radio player" + AppDescription = "A terminal-based music player for SomaFM radio stations" + AppAuthor = "Ilya Glebov" + AppAuthorURL = "https://ilyaglebov.dev" + AppAuthorURLShort = "ilyaglebov.dev" + AppProjectURL = "https://github.com/glebovdev/somafm-cli" + AppProjectShort = "github.com/glebovdev/somafm-cli" + AppDonateURL = "https://somafm.com/donate/" + AppDonateShort = "somafm.com/donate" + + ConfigDir = ".config/somafm" + ConfigFileName = "config.yml" + DefaultVolume = 70 + MinVolume = 0 + MaxVolume = 100 + DefaultBufferSecs = 5 + MinBufferSecs = 0 + MaxBufferSecs = 60 +) + +// ClampVolume ensures volume is within the valid range [0, 100]. +func ClampVolume(volume int) int { + if volume < MinVolume { + return MinVolume + } + if volume > MaxVolume { + return MaxVolume + } + return volume +} + +// AppVersion can be overridden at build time using ldflags: +// go build -ldflags "-X github.com/glebovdev/somafm-cli/internal/config.AppVersion=1.0.0" +var AppVersion = "dev" + +type Theme struct { + Background string `yaml:"background"` + Foreground string `yaml:"foreground"` + Borders string `yaml:"borders"` + Highlight string `yaml:"highlight"` + MutedVolume string `yaml:"muted_volume"` + HeaderBackground string `yaml:"header_background"` + StationListHeaderBackground string `yaml:"station_list_header_background"` + StationListHeaderForeground string `yaml:"station_list_header_foreground"` + HelpBackground string `yaml:"help_background"` + HelpForeground string `yaml:"help_foreground"` + HelpHotkey string `yaml:"help_hotkey"` + GenreTagBackground string `yaml:"genre_tag_background"` + ModalBackground string `yaml:"modal_background"` +} + +type Config struct { + Volume int `yaml:"volume"` + BufferSeconds int `yaml:"buffer_seconds"` + LastStation string `yaml:"last_station"` + Favorites []string `yaml:"favorites"` + Theme Theme `yaml:"theme"` +} + +func GetConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + configPath := filepath.Join(home, ConfigDir, ConfigFileName) + return configPath, nil +} + +func Load() (*Config, error) { + configPath, err := GetConfigPath() + if err != nil { + return DefaultConfig(), err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return DefaultConfig(), nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return DefaultConfig(), fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return DefaultConfig(), fmt.Errorf("failed to parse config file: %w", err) + } + + cfg.Volume = ClampVolume(cfg.Volume) + + if cfg.BufferSeconds < MinBufferSecs { + cfg.BufferSeconds = MinBufferSecs + } + if cfg.BufferSeconds > MaxBufferSecs { + cfg.BufferSeconds = MaxBufferSecs + } + if cfg.BufferSeconds == 0 && data != nil { + cfg.BufferSeconds = DefaultBufferSecs + } + + if cfg.Theme.Background == "" { + cfg.Theme = DefaultConfig().Theme + } + + return &cfg, nil +} + +// Save writes the configuration to disk atomically using temp file + rename. +func (c *Config) Save() error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + tmpFile, err := os.CreateTemp(configDir, ".config-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + defer func() { + if tmpPath != "" { + os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tmpPath, configPath); err != nil { + return fmt.Errorf("failed to rename config file: %w", err) + } + + tmpPath = "" // Prevent defer from removing the final file + return nil +} + +func DefaultConfig() *Config { + return &Config{ + Volume: DefaultVolume, + BufferSeconds: DefaultBufferSecs, + LastStation: "", + Favorites: []string{}, + Theme: Theme{ + Background: "#1a1b25", + Foreground: "#a3aacb", + Borders: "#40445b", + Highlight: "#ff9d65", + MutedVolume: "#fe0702", + HeaderBackground: "#473533", + StationListHeaderBackground: "#3a3d4f", + StationListHeaderForeground: "#c8d0e8", + HelpBackground: "#322f45", + HelpForeground: "#9aa3c6", + HelpHotkey: "#ff9d65", + GenreTagBackground: "#3a3d4f", + ModalBackground: "#282a36", + }, + } +} + +func (c *Config) IsFavorite(stationID string) bool { + for _, id := range c.Favorites { + if id == stationID { + return true + } + } + return false +} + +func (c *Config) ToggleFavorite(stationID string) { + for i, id := range c.Favorites { + if id == stationID { + c.Favorites = append(c.Favorites[:i], c.Favorites[i+1:]...) + return + } + } + c.Favorites = append(c.Favorites, stationID) +} + +func (c *Config) CleanupFavorites(validStationIDs map[string]bool) { + cleaned := []string{} + for _, id := range c.Favorites { + if validStationIDs[id] { + cleaned = append(cleaned, id) + } + } + c.Favorites = cleaned +} + +func GetColor(colorStr string) tcell.Color { + if colorStr == "" || colorStr == "default" { + return tcell.ColorDefault + } + return tcell.GetColor(colorStr) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1ff3429 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,525 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Volume != DefaultVolume { + t.Errorf("DefaultConfig().Volume = %d, want %d", cfg.Volume, DefaultVolume) + } + + if cfg.LastStation != "" { + t.Errorf("DefaultConfig().LastStation = %q, want empty string", cfg.LastStation) + } +} + +func TestConfigSaveAndLoad(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testCfg := &Config{ + Volume: 85, + LastStation: "groovesalad", + } + + err := testCfg.Save() + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + configPath := filepath.Join(tmpDir, ConfigDir, ConfigFileName) + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatalf("Config file was not created at %s", configPath) + } + + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loadedCfg.Volume != testCfg.Volume { + t.Errorf("Load().Volume = %d, want %d", loadedCfg.Volume, testCfg.Volume) + } + + if loadedCfg.LastStation != testCfg.LastStation { + t.Errorf("Load().LastStation = %q, want %q", loadedCfg.LastStation, testCfg.LastStation) + } +} + +func TestLoadNonExistentConfig(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + cfg, err := Load() + if err != nil { + t.Logf("Load() error (expected): %v", err) + } + + if cfg.Volume != DefaultVolume { + t.Errorf("Load() with non-existent file returned Volume = %d, want %d", cfg.Volume, DefaultVolume) + } + + if cfg.LastStation != "" { + t.Errorf("Load() with non-existent file returned LastStation = %q, want empty string", cfg.LastStation) + } +} + +func TestVolumeValidation(t *testing.T) { + tests := []struct { + name string + inputVolume int + expectedVolume int + }{ + {"valid volume 50", 50, 50}, + {"valid volume 0", 0, 0}, + {"valid volume 100", 100, 100}, + {"negative volume", -10, 0}, + {"volume over 100", 150, 100}, + {"volume way over 100", 1000, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testCfg := &Config{ + Volume: tt.inputVolume, + LastStation: "groovesalad", + } + + err := testCfg.Save() + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loadedCfg.Volume != tt.expectedVolume { + t.Errorf("Load().Volume = %d, want %d", loadedCfg.Volume, tt.expectedVolume) + } + }) + } +} + +func TestThemeDefaults(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + cfg, err := Load() + if err != nil { + t.Logf("Load() error (expected): %v", err) + } + + if cfg.Theme.Background != "#1a1b25" { + t.Errorf("Theme.Background = %q, want %q", cfg.Theme.Background, "#1a1b25") + } + if cfg.Theme.Foreground != "#a3aacb" { + t.Errorf("Theme.Foreground = %q, want %q", cfg.Theme.Foreground, "#a3aacb") + } + if cfg.Theme.Borders != "#40445b" { + t.Errorf("Theme.Borders = %q, want %q", cfg.Theme.Borders, "#40445b") + } + if cfg.Theme.Highlight != "#ff9d65" { + t.Errorf("Theme.Highlight = %q, want %q", cfg.Theme.Highlight, "#ff9d65") + } + if cfg.Theme.MutedVolume != "#fe0702" { + t.Errorf("Theme.MutedVolume = %q, want %q", cfg.Theme.MutedVolume, "#fe0702") + } +} + +func TestThemePersistence(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testCfg := &Config{ + Volume: 70, + LastStation: "groovesalad", + Theme: Theme{ + Background: "black", + Foreground: "yellow", + Borders: "blue", + Highlight: "red", + }, + } + + err := testCfg.Save() + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loadedCfg.Theme.Background != "black" { + t.Errorf("Theme.Background = %q, want %q", loadedCfg.Theme.Background, "black") + } + if loadedCfg.Theme.Foreground != "yellow" { + t.Errorf("Theme.Foreground = %q, want %q", loadedCfg.Theme.Foreground, "yellow") + } + if loadedCfg.Theme.Borders != "blue" { + t.Errorf("Theme.Borders = %q, want %q", loadedCfg.Theme.Borders, "blue") + } + if loadedCfg.Theme.Highlight != "red" { + t.Errorf("Theme.Highlight = %q, want %q", loadedCfg.Theme.Highlight, "red") + } +} + +func TestIsFavorite(t *testing.T) { + tests := []struct { + name string + favorites []string + stationID string + expected bool + }{ + { + name: "station is favorite", + favorites: []string{"groovesalad", "dronezone", "lush"}, + stationID: "dronezone", + expected: true, + }, + { + name: "station is not favorite", + favorites: []string{"groovesalad", "dronezone"}, + stationID: "lush", + expected: false, + }, + { + name: "empty favorites list", + favorites: []string{}, + stationID: "groovesalad", + expected: false, + }, + { + name: "first item in list", + favorites: []string{"groovesalad", "dronezone"}, + stationID: "groovesalad", + expected: true, + }, + { + name: "last item in list", + favorites: []string{"groovesalad", "dronezone", "lush"}, + stationID: "lush", + expected: true, + }, + { + name: "nil favorites", + favorites: nil, + stationID: "groovesalad", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Favorites: tt.favorites} + result := cfg.IsFavorite(tt.stationID) + if result != tt.expected { + t.Errorf("IsFavorite(%q) = %v, want %v", tt.stationID, result, tt.expected) + } + }) + } +} + +func TestToggleFavorite(t *testing.T) { + tests := []struct { + name string + initialFavorites []string + stationID string + expectedFavorites []string + }{ + { + name: "add to empty list", + initialFavorites: []string{}, + stationID: "groovesalad", + expectedFavorites: []string{"groovesalad"}, + }, + { + name: "add to existing list", + initialFavorites: []string{"dronezone"}, + stationID: "groovesalad", + expectedFavorites: []string{"dronezone", "groovesalad"}, + }, + { + name: "remove from list", + initialFavorites: []string{"groovesalad", "dronezone", "lush"}, + stationID: "dronezone", + expectedFavorites: []string{"groovesalad", "lush"}, + }, + { + name: "remove first item", + initialFavorites: []string{"groovesalad", "dronezone"}, + stationID: "groovesalad", + expectedFavorites: []string{"dronezone"}, + }, + { + name: "remove last item", + initialFavorites: []string{"groovesalad", "dronezone"}, + stationID: "dronezone", + expectedFavorites: []string{"groovesalad"}, + }, + { + name: "remove only item", + initialFavorites: []string{"groovesalad"}, + stationID: "groovesalad", + expectedFavorites: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Favorites: make([]string, len(tt.initialFavorites))} + copy(cfg.Favorites, tt.initialFavorites) + + cfg.ToggleFavorite(tt.stationID) + + if len(cfg.Favorites) != len(tt.expectedFavorites) { + t.Fatalf("ToggleFavorite(%q) resulted in %d favorites, want %d", + tt.stationID, len(cfg.Favorites), len(tt.expectedFavorites)) + } + + for i, fav := range cfg.Favorites { + if fav != tt.expectedFavorites[i] { + t.Errorf("Favorites[%d] = %q, want %q", i, fav, tt.expectedFavorites[i]) + } + } + }) + } +} + +func TestToggleFavoriteDoubleToggle(t *testing.T) { + cfg := &Config{Favorites: []string{}} + + cfg.ToggleFavorite("groovesalad") + if !cfg.IsFavorite("groovesalad") { + t.Error("After first toggle, groovesalad should be favorite") + } + + cfg.ToggleFavorite("groovesalad") + if cfg.IsFavorite("groovesalad") { + t.Error("After second toggle, groovesalad should not be favorite") + } +} + +func TestCleanupFavorites(t *testing.T) { + tests := []struct { + name string + initialFavorites []string + validStationIDs map[string]bool + expectedFavorites []string + }{ + { + name: "all valid", + initialFavorites: []string{"groovesalad", "dronezone"}, + validStationIDs: map[string]bool{"groovesalad": true, "dronezone": true, "lush": true}, + expectedFavorites: []string{"groovesalad", "dronezone"}, + }, + { + name: "some invalid", + initialFavorites: []string{"groovesalad", "deleted_station", "dronezone"}, + validStationIDs: map[string]bool{"groovesalad": true, "dronezone": true}, + expectedFavorites: []string{"groovesalad", "dronezone"}, + }, + { + name: "all invalid", + initialFavorites: []string{"deleted1", "deleted2"}, + validStationIDs: map[string]bool{"groovesalad": true}, + expectedFavorites: []string{}, + }, + { + name: "empty favorites", + initialFavorites: []string{}, + validStationIDs: map[string]bool{"groovesalad": true}, + expectedFavorites: []string{}, + }, + { + name: "empty valid IDs", + initialFavorites: []string{"groovesalad"}, + validStationIDs: map[string]bool{}, + expectedFavorites: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{Favorites: make([]string, len(tt.initialFavorites))} + copy(cfg.Favorites, tt.initialFavorites) + + cfg.CleanupFavorites(tt.validStationIDs) + + if len(cfg.Favorites) != len(tt.expectedFavorites) { + t.Fatalf("CleanupFavorites resulted in %d favorites, want %d", + len(cfg.Favorites), len(tt.expectedFavorites)) + } + + for i, fav := range cfg.Favorites { + if fav != tt.expectedFavorites[i] { + t.Errorf("Favorites[%d] = %q, want %q", i, fav, tt.expectedFavorites[i]) + } + } + }) + } +} + +func TestGetColor(t *testing.T) { + tests := []struct { + name string + colorStr string + isNonNil bool + }{ + {"empty string returns default", "", true}, + {"default keyword returns default", "default", true}, + {"named color white", "white", true}, + {"named color red", "red", true}, + {"named color darkcyan", "darkcyan", true}, + {"hex color", "#FF0000", true}, + {"hex color lowercase", "#ff0000", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetColor(tt.colorStr) + if tt.colorStr == "" || tt.colorStr == "default" { + if result != 0 { + t.Errorf("GetColor(%q) = %v, want ColorDefault (0)", tt.colorStr, result) + } + } + }) + } +} + +func TestBufferSecondsValidation(t *testing.T) { + tests := []struct { + name string + inputBuffer int + expectedBuffer int + }{ + {"valid buffer 5", 5, 5}, + {"valid buffer 0", 0, DefaultBufferSecs}, + {"valid buffer 60", 60, 60}, + {"negative buffer", -10, DefaultBufferSecs}, + {"buffer over 60", 100, MaxBufferSecs}, + {"buffer way over max", 1000, MaxBufferSecs}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testCfg := &Config{ + Volume: 70, + BufferSeconds: tt.inputBuffer, + Theme: DefaultConfig().Theme, + } + + err := testCfg.Save() + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if loadedCfg.BufferSeconds != tt.expectedBuffer { + t.Errorf("Load().BufferSeconds = %d, want %d", loadedCfg.BufferSeconds, tt.expectedBuffer) + } + }) + } +} + +func TestFavoritesPersistence(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testCfg := &Config{ + Volume: 70, + Favorites: []string{"groovesalad", "dronezone", "lush"}, + Theme: DefaultConfig().Theme, + } + + err := testCfg.Save() + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + loadedCfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if len(loadedCfg.Favorites) != 3 { + t.Fatalf("Load().Favorites has %d items, want 3", len(loadedCfg.Favorites)) + } + + expected := []string{"groovesalad", "dronezone", "lush"} + for i, fav := range loadedCfg.Favorites { + if fav != expected[i] { + t.Errorf("Favorites[%d] = %q, want %q", i, fav, expected[i]) + } + } +} + +func TestLoadInvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + configDir := filepath.Join(tmpDir, ConfigDir) + _ = os.MkdirAll(configDir, 0755) + configPath := filepath.Join(configDir, ConfigFileName) + + invalidYAML := []byte("this is not: valid: yaml: [") + _ = os.WriteFile(configPath, invalidYAML, 0644) + + cfg, err := Load() + if err == nil { + t.Log("Load() returned no error for invalid YAML, but returned default config") + } + + if cfg.Volume != DefaultVolume { + t.Errorf("Load() with invalid YAML returned Volume = %d, want default %d", cfg.Volume, DefaultVolume) + } +} + +func TestGetConfigPath(t *testing.T) { + path, err := GetConfigPath() + if err != nil { + t.Fatalf("GetConfigPath() error = %v", err) + } + + if path == "" { + t.Error("GetConfigPath() returned empty string") + } + + if !filepath.IsAbs(path) { + t.Errorf("GetConfigPath() = %q, want absolute path", path) + } +} diff --git a/internal/player/player.go b/internal/player/player.go new file mode 100644 index 0000000..cd443a5 --- /dev/null +++ b/internal/player/player.go @@ -0,0 +1,1062 @@ +package player + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + "sync" + "time" + + "github.com/glebovdev/somafm-cli/internal/config" + "github.com/glebovdev/somafm-cli/internal/station" + "github.com/gopxl/beep/v2" + "github.com/gopxl/beep/v2/effects" + "github.com/gopxl/beep/v2/mp3" + "github.com/gopxl/beep/v2/speaker" + "github.com/rs/zerolog/log" +) + +const ( + DefaultSampleRate = beep.SampleRate(44100) + SpeakerBufferSize = time.Millisecond * 250 + NetworkReadSize = 4096 + SampleChannelSize = 8192 + MaxRetries = 3 + RetryDelay = time.Second * 2 + VolumeCurveExponent = 0.5 + MinVolumeDB = -10.0 + ReadTimeout = 10 * time.Second + MaxErrorsToKeep = 10 // Limit error accumulation during retries +) + +type PlayerState int + +const ( + StateIdle PlayerState = iota + StateBuffering + StatePlaying + StatePaused + StateReconnecting + StateError +) + +func (s PlayerState) String() string { + switch s { + case StateIdle: + return "IDLE" + case StateBuffering: + return "BUFFERING" + case StatePlaying: + return "LIVE" + case StatePaused: + return "PAUSED" + case StateReconnecting: + return "RECONNECTING" + case StateError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// StreamInfo contains metadata about the current audio stream. +type StreamInfo struct { + Format string + Quality string + Bitrate int + SampleRate int +} + +// contextReader wraps a reader with context-aware timeout detection. +// When a read blocks longer than the timeout, it returns an error +// without leaking goroutines (relies on context cancellation for cleanup). +type contextReader struct { + reader io.Reader + ctx context.Context + timeout time.Duration +} + +func (cr *contextReader) Read(p []byte) (n int, err error) { + select { + case <-cr.ctx.Done(): + return 0, cr.ctx.Err() + default: + } + + timer := time.NewTimer(cr.timeout) + defer timer.Stop() + + type result struct { + n int + err error + } + done := make(chan result, 1) + + go func() { + n, err := cr.reader.Read(p) + select { + case done <- result{n, err}: + case <-cr.ctx.Done(): + } + }() + + select { + case res := <-done: + return res.n, res.err + case <-timer.C: + return 0, fmt.Errorf("read timeout: no data received for %v", cr.timeout) + case <-cr.ctx.Done(): + return 0, cr.ctx.Err() + } +} + +// Player handles audio streaming and playback for SomaFM radio stations. +// It manages the audio pipeline including network streaming, decoding, buffering, +// and volume control. +type Player struct { + format beep.Format + volume *effects.Volume + ctrl *beep.Ctrl + mu sync.Mutex + cancelFunc context.CancelFunc + isPaused bool + isPlaying bool + speakerInit bool + volumePercent int + httpClient *http.Client // Reused for all stream connections + + buffer [][2]float64 + bufferSize int + writeIdx int64 + readBackOffset int + bufferMu sync.Mutex + sampleCh chan [2]float64 + wg sync.WaitGroup + streamDone chan struct{} + streamDoneOnce sync.Once // Prevents double-close panic on streamDone + streamErr chan error + + currentTrack string + trackMu sync.RWMutex + + state PlayerState + streamInfo StreamInfo + retryAttempt int + maxRetries int + sessionStart time.Time + lastError string + stateMu sync.RWMutex + + currentStation *station.Station + streamAlive bool + streamAliveMu sync.RWMutex +} + +// closeStreamDone safely closes the streamDone channel exactly once. +// This prevents panics from double-close when multiple goroutines try to signal completion. +func (p *Player) closeStreamDone() { + p.streamDoneOnce.Do(func() { + if p.streamDone != nil { + close(p.streamDone) + } + }) +} + +// NewPlayer creates a new Player with the specified buffer size in seconds. +func NewPlayer(bufferSeconds int) *Player { + var buffer [][2]float64 + if bufferSeconds > 0 { + bufferLen := int(DefaultSampleRate) * bufferSeconds + buffer = make([][2]float64, bufferLen) + log.Debug().Msgf("Initialized circular buffer: %d seconds (%d samples, ~%.2f MB)", + bufferSeconds, bufferLen, float64(bufferLen*2*8)/1000000) + } + + // Create a reusable HTTP client with appropriate settings for streaming + httpClient := &http.Client{ + Timeout: 0, // No timeout for streaming connections + Transport: &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: true, // Audio streams are already compressed + }, + } + + return &Player{ + format: beep.Format{ + SampleRate: DefaultSampleRate, + NumChannels: 2, + Precision: 2, + }, + speakerInit: false, + isPaused: false, + isPlaying: false, + volumePercent: -1, + httpClient: httpClient, + buffer: buffer, + bufferSize: bufferSeconds, + currentTrack: "", + } +} + +func (p *Player) initSpeaker(sampleRate beep.SampleRate) error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.speakerInit || sampleRate != p.format.SampleRate { + err := speaker.Init(sampleRate, sampleRate.N(SpeakerBufferSize)) + if err != nil { + return fmt.Errorf("failed to initialize speaker: %w", err) + } + p.format.SampleRate = sampleRate + p.speakerInit = true + log.Debug().Msgf("Speaker initialized with sample rate: %d Hz, buffer: %v", sampleRate, SpeakerBufferSize) + } + return nil +} + +func (p *Player) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cancelFunc != nil { + p.cancelFunc() + p.cancelFunc = nil + } + + speaker.Clear() + p.isPlaying = false + p.isPaused = false + + p.streamAliveMu.Lock() + p.streamAlive = false + p.streamAliveMu.Unlock() + + p.stateMu.Lock() + p.state = StateIdle + p.sessionStart = time.Time{} + p.streamInfo = StreamInfo{} + p.stateMu.Unlock() + + log.Debug().Msg("Playback stopped") +} + +func (p *Player) TogglePause() { + p.mu.Lock() + defer p.mu.Unlock() + + if p.ctrl == nil || !p.isPlaying { + return + } + + speaker.Lock() + p.ctrl.Paused = !p.ctrl.Paused + p.isPaused = p.ctrl.Paused + speaker.Unlock() + + if p.isPaused { + p.stateMu.Lock() + p.state = StatePaused + p.stateMu.Unlock() + log.Debug().Msg("Playback paused") + } else { + p.stateMu.Lock() + p.state = StatePlaying + p.stateMu.Unlock() + log.Debug().Msg("Playback resumed") + } +} + +func (p *Player) SetVolume(volumePercent int) { + p.mu.Lock() + defer p.mu.Unlock() + + p.volumePercent = volumePercent + + if p.volume == nil { + log.Debug().Msgf("Volume stored as %d%% (will be applied when playback starts)", volumePercent) + return + } + + volumeLevel := percentToExponent(float64(volumePercent)) + + speaker.Lock() + p.volume.Volume = volumeLevel + p.volume.Silent = volumePercent == 0 + speaker.Unlock() + + log.Debug().Msgf("Volume set to %d%% (%.2f dB)", volumePercent, volumeLevel) +} + +func percentToExponent(p float64) float64 { + if p <= 0 { + return MinVolumeDB + } + if p >= 100 { + return 0 + } + + normalized := p / 100.0 + adjusted := math.Pow(normalized, VolumeCurveExponent) + return (1.0 - adjusted) * MinVolumeDB +} + +func (p *Player) IsPlaying() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.isPlaying && !p.isPaused +} + +func (p *Player) IsPaused() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.isPaused +} + +func (p *Player) GetCurrentTrack() string { + p.trackMu.RLock() + defer p.trackMu.RUnlock() + + if p.currentTrack == "" { + return "Waiting for track info..." + } + return p.currentTrack +} + +func (p *Player) GetCurrentStation() *station.Station { + p.mu.Lock() + defer p.mu.Unlock() + return p.currentStation +} + +func (p *Player) setCurrentTrack(track string) { + p.trackMu.Lock() + defer p.trackMu.Unlock() + + if track != p.currentTrack { + p.currentTrack = track + log.Debug().Msgf("Now playing: %s", track) + } +} + +func (p *Player) SetInitialTrack(track string) { + p.trackMu.Lock() + defer p.trackMu.Unlock() + + // Don't overwrite ICY metadata if already set + if p.currentTrack == "" { + p.currentTrack = track + log.Debug().Msgf("Initial track set from songs API: %s", track) + } +} + +func (p *Player) GetState() PlayerState { + p.stateMu.RLock() + defer p.stateMu.RUnlock() + return p.state +} + +func (p *Player) setState(state PlayerState) { + p.stateMu.Lock() + defer p.stateMu.Unlock() + if p.state != state { + log.Debug().Msgf("Player state: %s -> %s", p.state.String(), state.String()) + p.state = state + } +} + +func (p *Player) GetStreamInfo() StreamInfo { + p.stateMu.RLock() + defer p.stateMu.RUnlock() + return p.streamInfo +} + +func (p *Player) setStreamInfo(info StreamInfo) { + p.stateMu.Lock() + defer p.stateMu.Unlock() + p.streamInfo = info + log.Debug().Msgf("Stream info: %s %dk %dHz", info.Format, info.Bitrate, info.SampleRate) +} + +func (p *Player) GetBufferFillPercent() int { + if len(p.buffer) == 0 { + return 0 + } + + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + + bufferLen := int64(len(p.buffer)) + fillLevel := p.writeIdx + if fillLevel > bufferLen { + fillLevel = bufferLen + } + + return int((fillLevel * 100) / bufferLen) +} + +// GetBufferHealth returns the current buffer fill level as a percentage (0-100). +func (p *Player) GetBufferHealth() int { + p.mu.Lock() + ch := p.sampleCh + p.mu.Unlock() + + if ch == nil { + return 0 + } + + channelLen := len(ch) + channelCap := cap(ch) + + if channelCap == 0 { + return 0 + } + + return (channelLen * 100) / channelCap +} + +func (p *Player) GetRetryInfo() (current, max int) { + p.stateMu.RLock() + defer p.stateMu.RUnlock() + return p.retryAttempt, p.maxRetries +} + +func (p *Player) setRetryInfo(current, max int) { + p.stateMu.Lock() + defer p.stateMu.Unlock() + p.retryAttempt = current + p.maxRetries = max +} + +func (p *Player) GetSessionDuration() time.Duration { + p.stateMu.RLock() + defer p.stateMu.RUnlock() + + if p.sessionStart.IsZero() { + return 0 + } + return time.Since(p.sessionStart) +} + +func (p *Player) startSession() { + p.stateMu.Lock() + defer p.stateMu.Unlock() + p.sessionStart = time.Now() +} + +func (p *Player) GetLastError() string { + p.stateMu.RLock() + defer p.stateMu.RUnlock() + return p.lastError +} + +func (p *Player) setLastError(err string) { + p.stateMu.Lock() + defer p.stateMu.Unlock() + p.lastError = err +} + +func (p *Player) Play(s *station.Station) error { + return p.PlayWithRetry(s, MaxRetries) +} + +func (p *Player) PlayWithRetry(s *station.Station, maxRetries int) error { + playlistURLs := s.GetAllPlaylistURLs() + if len(playlistURLs) == 0 { + p.setState(StateError) + p.setLastError("No playlists available") + return fmt.Errorf("no playlists available for station: %s", s.Title) + } + + p.setState(StateBuffering) + p.setRetryInfo(0, maxRetries) + + allErrors := make([]string, 0, MaxErrorsToKeep) + totalAttempts := 0 + + addError := func(msg string) { + if len(allErrors) < MaxErrorsToKeep { + allErrors = append(allErrors, msg) + } + } + + for playlistIdx, playlistURL := range playlistURLs { + log.Debug().Msgf("Trying playlist %d/%d: %s", playlistIdx+1, len(playlistURLs), playlistURL) + + streamInfo := parseStreamInfoFromURL(playlistURL) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + streamURLs, err := p.fetchAndParsePLS(ctx, playlistURL) + cancel() + + if err != nil { + log.Warn().Err(err).Msgf("Failed to fetch playlist: %s", playlistURL) + addError(fmt.Sprintf("playlist %s: %v", playlistURL, err)) + continue + } + + log.Debug().Msgf("Found %d stream URLs in playlist", len(streamURLs)) + + for urlIdx, streamURL := range streamURLs { + for attempt := 1; attempt <= maxRetries; attempt++ { + totalAttempts++ + + if attempt > 1 { + p.setState(StateReconnecting) + p.setRetryInfo(attempt, maxRetries) + } + + log.Debug().Msgf("Trying stream %d/%d (attempt %d/%d): %s", + urlIdx+1, len(streamURLs), attempt, maxRetries, streamURL) + + ctx, cancel := context.WithCancel(context.Background()) + + p.mu.Lock() + if p.cancelFunc != nil { + p.cancelFunc() + } + p.cancelFunc = cancel + p.mu.Unlock() + + p.setStreamInfo(streamInfo) + + err := p.playStreamURL(ctx, s, streamURL) + if err == nil { + return nil + } + + if errors.Is(err, context.Canceled) { + return context.Canceled + } + + if isNonRetryableError(err) { + log.Warn().Err(err).Msgf("Non-retryable error for %s, moving to next URL", streamURL) + addError(fmt.Sprintf("%s: %v", streamURL, err)) + break + } + + if isNetworkDownError(err) { + log.Warn().Err(err).Msg("Network appears to be down, stopping retries") + p.setState(StateError) + p.setLastError("Network connection lost") + return fmt.Errorf("network connection lost: %w", err) + } + + addError(fmt.Sprintf("%s (attempt %d): %v", streamURL, attempt, err)) + + if attempt < maxRetries { + log.Warn().Err(err).Msgf("Stream failed, retrying in %v...", RetryDelay) + time.Sleep(RetryDelay) + } + } + } + } + + p.setState(StateError) + p.setLastError("Connection failed") + return fmt.Errorf("playback failed after %d total attempts across all streams. Errors: %s", + totalAttempts, strings.Join(allErrors, "; ")) +} + +func isNonRetryableError(err error) bool { + errStr := err.Error() + // HTTP errors that won't change with retry on this specific URL + return strings.Contains(errStr, "status 401") || + strings.Contains(errStr, "status 403") || + strings.Contains(errStr, "status 404") || + strings.Contains(errStr, "status 410") +} + +func isNetworkDownError(err error) bool { + errStr := err.Error() + return strings.Contains(errStr, "no such host") || + strings.Contains(errStr, "network is unreachable") || + strings.Contains(errStr, "no route to host") || + strings.Contains(errStr, "network is down") || + strings.Contains(errStr, "DNS lookup failed") || + strings.Contains(errStr, "read timeout") +} + +func (p *Player) playStreamURL(ctx context.Context, s *station.Station, streamURL string) error { + speaker.Clear() + p.setCurrentTrack("") + + log.Debug().Msgf("Connecting to stream: %s", streamURL) + + req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", fmt.Sprintf("SomaFM-CLI/%s", config.AppVersion)) + req.Header.Set("Icy-MetaData", "1") + + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch MP3 stream: %w", err) + } + + log.Debug().Msgf("Stream response status: %d, Content-Type: %s", resp.StatusCode, resp.Header.Get("Content-Type")) + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return fmt.Errorf("stream returned status %d: %s", resp.StatusCode, resp.Status) + } + + var icyMetaint int + if val := resp.Header.Get("icy-metaint"); val != "" { + _, _ = fmt.Sscanf(val, "%d", &icyMetaint) + log.Debug().Msgf("ICY metadata interval: %d bytes", icyMetaint) + } + + pipeReader, pipeWriter := io.Pipe() + + p.mu.Lock() + p.sampleCh = make(chan [2]float64, SampleChannelSize) + p.streamDone = make(chan struct{}) + p.streamDoneOnce = sync.Once{} // Reset for new stream + p.streamErr = make(chan error, 1) + p.mu.Unlock() + + if len(p.buffer) > 0 { + p.bufferMu.Lock() + for i := range p.buffer { + p.buffer[i] = [2]float64{0, 0} + } + p.writeIdx = 0 + p.readBackOffset = 0 + p.bufferMu.Unlock() + } + + // Use context-aware reader for timeout detection + // The goroutine in contextReader will exit when context is cancelled + timeoutBody := &contextReader{ + reader: resp.Body, + ctx: ctx, + timeout: ReadTimeout, + } + + p.wg.Add(1) + go p.readNetworkStream(ctx, resp.Body, timeoutBody, pipeWriter, icyMetaint) + + log.Debug().Msg("Decoding MP3 stream...") + streamer, format, err := mp3.Decode(pipeReader) + if err != nil { + pipeReader.Close() + pipeWriter.Close() + resp.Body.Close() + return fmt.Errorf("failed to decode MP3 stream: %w", err) + } + + log.Debug().Msgf("Initializing audio output (sample rate: %d Hz)...", format.SampleRate) + if err := p.initSpeaker(format.SampleRate); err != nil { + streamer.Close() + pipeReader.Close() + pipeWriter.Close() + resp.Body.Close() + return fmt.Errorf("failed to initialize audio output: %w", err) + } + + p.mu.Lock() + p.format = format + p.mu.Unlock() + + p.streamAliveMu.Lock() + p.streamAlive = true + p.streamAliveMu.Unlock() + + p.mu.Lock() + p.currentStation = s + p.mu.Unlock() + + p.wg.Add(1) + go p.decodeAndBuffer(ctx, streamer, pipeReader, resp.Body) + + p.mu.Lock() + volumePercent := p.volumePercent + if volumePercent < 0 { + volumePercent = config.DefaultVolume + } + volumeLevel := percentToExponent(float64(volumePercent)) + + bufferedStreamer := &bufferedStreamerWrapper{player: p} + + p.volume = &effects.Volume{ + Streamer: bufferedStreamer, + Base: 2, + Volume: volumeLevel, + Silent: volumePercent == 0, + } + + p.ctrl = &beep.Ctrl{ + Streamer: p.volume, + Paused: false, + } + p.isPlaying = true + p.isPaused = false + p.mu.Unlock() + + speaker.Play(p.ctrl) + + p.setState(StatePlaying) + p.startSession() + + p.stateMu.Lock() + p.streamInfo.SampleRate = int(format.SampleRate) + p.stateMu.Unlock() + + log.Debug().Msgf("Now playing: %s (buffer: %ds)", s.Title, p.bufferSize) + + select { + case <-ctx.Done(): + speaker.Clear() + p.mu.Lock() + p.isPlaying = false + p.isPaused = false + p.mu.Unlock() + + p.closeStreamDone() + p.wg.Wait() + + return ctx.Err() + case err := <-p.streamErr: + speaker.Clear() + p.mu.Lock() + p.isPlaying = false + p.isPaused = false + p.mu.Unlock() + + p.closeStreamDone() + p.wg.Wait() + + return fmt.Errorf("stream error: %w", err) + case <-p.streamDone: + p.mu.Lock() + p.isPlaying = false + p.isPaused = false + p.mu.Unlock() + return fmt.Errorf("stream ended unexpectedly") + } +} + +func (p *Player) readNetworkStream(ctx context.Context, respBody io.ReadCloser, bodyReader io.Reader, pipeWriter *io.PipeWriter, icyMetaint int) { + defer func() { + respBody.Close() + pipeWriter.Close() + p.wg.Done() + log.Debug().Msg("Network stream reader stopped") + }() + + reportError := func(err error) { + select { + case p.streamErr <- err: + default: + // Channel full or closed, error already reported + } + } + + chunkSize := int64(icyMetaint) + if chunkSize == 0 { + chunkSize = NetworkReadSize + } + + bufReader := bufio.NewReader(bodyReader) + + for { + select { + case <-ctx.Done(): + log.Debug().Msg("Network reader context cancelled") + return + case <-p.streamDone: + return + default: + _, err := io.CopyN(pipeWriter, bufReader, chunkSize) + if err != nil { + // Don't log errors during intentional shutdown (station switch) + if ctx.Err() != nil || errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "closed pipe") { + return + } + if err != io.EOF { + log.Error().Err(err).Msg("Error reading audio data from stream") + reportError(fmt.Errorf("network read error: %w", err)) + } + return + } + + if icyMetaint > 0 { + metaLenByte, err := bufReader.ReadByte() + if err != nil { + if ctx.Err() != nil || err == io.EOF { + return + } + log.Error().Err(err).Msg("Error reading metadata length") + reportError(fmt.Errorf("metadata read error: %w", err)) + return + } + + metaLen := int(metaLenByte) * 16 + if metaLen > 0 { + metaData := make([]byte, metaLen) + n, err := io.ReadFull(bufReader, metaData) + if err != nil { + if ctx.Err() != nil { + return + } + log.Error().Err(err).Msg("Error reading metadata content") + reportError(fmt.Errorf("metadata content error: %w", err)) + return + } + + metaStr := string(metaData[:n]) + if strings.Contains(metaStr, "StreamTitle='") { + start := strings.Index(metaStr, "StreamTitle='") + len("StreamTitle='") + end := strings.Index(metaStr[start:], "';") + if end > 0 { + title := metaStr[start : start+end] + p.setCurrentTrack(title) + } + } + } + } + } + } +} + +func (p *Player) decodeAndBuffer(ctx context.Context, streamer beep.StreamSeekCloser, pipeReader *io.PipeReader, respBody io.ReadCloser) { + defer func() { + streamer.Close() + pipeReader.Close() + close(p.sampleCh) + p.wg.Done() + + p.streamAliveMu.Lock() + p.streamAlive = false + p.streamAliveMu.Unlock() + + log.Debug().Msg("Decoder and buffer goroutine stopped") + + if ctx.Err() == nil { + p.mu.Lock() + station := p.currentStation + stationID := "" + if station != nil { + stationID = station.ID + } + shouldReconnect := p.isPlaying && !p.isPaused + p.mu.Unlock() + + if shouldReconnect && station != nil { + log.Info().Msg("Stream ended unexpectedly, auto-reconnecting...") + go func() { + p.setState(StateReconnecting) + p.Stop() + + // Verify station hasn't changed during reconnect + p.mu.Lock() + currentStation := p.currentStation + stationChanged := currentStation == nil || currentStation.ID != stationID + p.mu.Unlock() + + if stationChanged { + log.Debug().Msg("Station changed during reconnect, aborting") + return + } + + if err := p.Play(station); err != nil { + log.Error().Err(err).Msg("Auto-reconnect failed") + p.setState(StateError) + p.setLastError("Reconnection failed") + } + }() + } + } + }() + + decodedSamples := make([][2]float64, 4096) + + for { + select { + case <-ctx.Done(): + return + case <-p.streamDone: + return + default: + n, ok := streamer.Stream(decodedSamples) + if !ok { + if err := streamer.Err(); err != nil { + log.Error().Err(err).Msg("Stream decoding error") + } + return + } + + for i := 0; i < n; i++ { + sample := decodedSamples[i] + + select { + case <-ctx.Done(): + return + case <-p.streamDone: + return + case p.sampleCh <- sample: + if len(p.buffer) > 0 { + p.bufferMu.Lock() + idx := p.writeIdx % int64(len(p.buffer)) + p.buffer[idx] = sample + p.writeIdx++ + p.bufferMu.Unlock() + } + } + } + } + } +} + +type bufferedStreamerWrapper struct { + player *Player +} + +func (b *bufferedStreamerWrapper) Stream(samples [][2]float64) (n int, ok bool) { + p := b.player + i := 0 + + if len(p.buffer) > 0 { + i = b.readFromBuffer(samples) + if i == len(samples) { + return i, true + } + } + + return b.readFromChannel(samples, i) +} + +func (b *bufferedStreamerWrapper) readFromBuffer(samples [][2]float64) int { + p := b.player + i := 0 + + p.bufferMu.Lock() + defer p.bufferMu.Unlock() + + for p.readBackOffset < 0 && i < len(samples) { + bufLen := int64(len(p.buffer)) + idx := (p.writeIdx + int64(p.readBackOffset) + bufLen) % bufLen + + if idx >= p.writeIdx { + break + } + + samples[i] = p.buffer[idx] + p.readBackOffset++ + i++ + } + + return i +} + +func (b *bufferedStreamerWrapper) readFromChannel(samples [][2]float64, startIdx int) (n int, ok bool) { + p := b.player + i := startIdx + + for i < len(samples) { + select { + case sample, more := <-p.sampleCh: + if !more { + return i, i > 0 + } + samples[i] = sample + i++ + case <-p.streamDone: + return i, i > 0 + } + } + + return len(samples), len(samples) > 0 +} + +func (b *bufferedStreamerWrapper) Err() error { + return nil +} + +func (p *Player) fetchAndParsePLS(ctx context.Context, plsURL string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", plsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create PLS request: %w", err) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch PLS file: %w", err) + } + defer resp.Body.Close() + + var urls []string + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "File") && strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + url := strings.TrimSpace(parts[1]) + if url != "" { + urls = append(urls, url) + } + } + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading PLS file: %w", err) + } + + if len(urls) == 0 { + return nil, fmt.Errorf("no valid stream URL found in PLS file") + } + + return urls, nil +} + +// parseStreamInfoFromURL extracts format and bitrate from SomaFM playlist URLs. +// URL patterns: groovesalad130.pls (MP3 128k), groovesalad-aac.pls (AAC), etc. +func parseStreamInfoFromURL(url string) StreamInfo { + info := StreamInfo{ + Format: "MP3", + Quality: "high", + Bitrate: 128, + SampleRate: 44100, + } + + urlLower := strings.ToLower(url) + + if strings.Contains(urlLower, "aac") || strings.Contains(urlLower, "aacp") { + info.Format = "AAC" + } + + bitrates := []int{320, 256, 192, 130, 128, 64, 32} + for _, br := range bitrates { + brStr := fmt.Sprintf("%d", br) + if strings.Contains(url, brStr+".pls") || strings.Contains(url, brStr+".") { + info.Bitrate = br + if br == 130 { // SomaFM uses 130 for 128kbps streams + info.Bitrate = 128 + } + break + } + } + + switch { + case info.Bitrate >= 256: + info.Quality = "highest" + case info.Bitrate >= 128: + info.Quality = "high" + case info.Bitrate >= 64: + info.Quality = "medium" + default: + info.Quality = "low" + } + + return info +} diff --git a/internal/player/player_test.go b/internal/player/player_test.go new file mode 100644 index 0000000..d3e7bab --- /dev/null +++ b/internal/player/player_test.go @@ -0,0 +1,503 @@ +package player + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestPercentToExponent(t *testing.T) { + tests := []struct { + percent float64 + expected float64 + }{ + {0, MinVolumeDB}, + {100, 0}, + {-10, MinVolumeDB}, + {150, 0}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("percent_%v", tt.percent), func(t *testing.T) { + result := percentToExponent(tt.percent) + if result != tt.expected { + t.Errorf("percentToExponent(%v) = %v, want %v", tt.percent, result, tt.expected) + } + }) + } +} + +func TestPercentToExponentCurve(t *testing.T) { + p25 := percentToExponent(25) + p50 := percentToExponent(50) + p75 := percentToExponent(75) + + if p25 >= p50 || p50 >= p75 { + t.Error("Volume curve should be monotonically increasing") + } + + if p25 <= MinVolumeDB || p75 >= 0 { + t.Error("Mid-range volumes should be between min and max") + } +} + +func TestPlayerStateString(t *testing.T) { + tests := []struct { + state PlayerState + expected string + }{ + {StateIdle, "IDLE"}, + {StateBuffering, "BUFFERING"}, + {StatePlaying, "LIVE"}, + {StatePaused, "PAUSED"}, + {StateReconnecting, "RECONNECTING"}, + {StateError, "ERROR"}, + {PlayerState(99), "UNKNOWN"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.state.String() + if result != tt.expected { + t.Errorf("PlayerState(%d).String() = %q, want %q", tt.state, result, tt.expected) + } + }) + } +} + +func TestNewPlayer(t *testing.T) { + tests := []struct { + bufferSeconds int + expectBuffer bool + }{ + {0, false}, + {5, true}, + {10, true}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("buffer_%ds", tt.bufferSeconds), func(t *testing.T) { + p := NewPlayer(tt.bufferSeconds) + + if p == nil { + t.Fatal("NewPlayer returned nil") + } + + if tt.expectBuffer && len(p.buffer) == 0 { + t.Error("Expected buffer to be allocated") + } + + if !tt.expectBuffer && len(p.buffer) != 0 { + t.Error("Expected no buffer") + } + + if p.isPlaying { + t.Error("New player should not be playing") + } + + if p.isPaused { + t.Error("New player should not be paused") + } + }) + } +} + +func TestNewPlayerBufferSize(t *testing.T) { + p := NewPlayer(5) + expectedSize := int(DefaultSampleRate) * 5 + + if len(p.buffer) != expectedSize { + t.Errorf("Buffer size = %d, want %d", len(p.buffer), expectedSize) + } +} + +func TestIsNonRetryableError(t *testing.T) { + tests := []struct { + err error + expected bool + }{ + {errors.New("status 401"), true}, + {errors.New("status 403"), true}, + {errors.New("status 404"), true}, + {errors.New("status 410"), true}, + {errors.New("status 500"), false}, + {errors.New("connection refused"), false}, + {errors.New("timeout"), false}, + } + + for _, tt := range tests { + t.Run(tt.err.Error(), func(t *testing.T) { + result := isNonRetryableError(tt.err) + if result != tt.expected { + t.Errorf("isNonRetryableError(%q) = %v, want %v", tt.err, result, tt.expected) + } + }) + } +} + +func TestIsNetworkDownError(t *testing.T) { + tests := []struct { + err error + expected bool + }{ + {errors.New("no such host"), true}, + {errors.New("network is unreachable"), true}, + {errors.New("no route to host"), true}, + {errors.New("network is down"), true}, + {errors.New("DNS lookup failed"), true}, + {errors.New("read timeout"), true}, + {errors.New("connection refused"), false}, + {errors.New("status 500"), false}, + } + + for _, tt := range tests { + t.Run(tt.err.Error(), func(t *testing.T) { + result := isNetworkDownError(tt.err) + if result != tt.expected { + t.Errorf("isNetworkDownError(%q) = %v, want %v", tt.err, result, tt.expected) + } + }) + } +} + +func TestParseStreamInfoFromURL(t *testing.T) { + tests := []struct { + url string + expectedFormat string + expectedBitrate int + expectedQuality string + }{ + {"https://somafm.com/groovesalad130.pls", "MP3", 128, "high"}, + {"https://somafm.com/groovesalad256.pls", "MP3", 256, "highest"}, + {"https://somafm.com/groovesalad64.pls", "MP3", 64, "medium"}, + {"https://somafm.com/groovesalad32.pls", "MP3", 32, "low"}, + {"https://somafm.com/groovesalad.pls", "MP3", 128, "high"}, + {"https://somafm.com/groovesalad-aac.pls", "AAC", 128, "high"}, + {"https://somafm.com/groovesalad-aacp64.pls", "AAC", 64, "medium"}, + {"https://somafm.com/station320.pls", "MP3", 320, "highest"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + info := parseStreamInfoFromURL(tt.url) + + if info.Format != tt.expectedFormat { + t.Errorf("Format = %q, want %q", info.Format, tt.expectedFormat) + } + if info.Bitrate != tt.expectedBitrate { + t.Errorf("Bitrate = %d, want %d", info.Bitrate, tt.expectedBitrate) + } + if info.Quality != tt.expectedQuality { + t.Errorf("Quality = %q, want %q", info.Quality, tt.expectedQuality) + } + }) + } +} + +func TestPlayerTrackManagement(t *testing.T) { + p := NewPlayer(0) + + initial := p.GetCurrentTrack() + if initial != "Waiting for track info..." { + t.Errorf("Initial track = %q, want 'Waiting for track info...'", initial) + } + + p.SetInitialTrack("Test Song - Test Artist") + track := p.GetCurrentTrack() + if track != "Test Song - Test Artist" { + t.Errorf("After SetInitialTrack, track = %q", track) + } + + p.SetInitialTrack("Should Not Override") + track = p.GetCurrentTrack() + if track != "Test Song - Test Artist" { + t.Errorf("SetInitialTrack should not override existing track, got %q", track) + } + + p.setCurrentTrack("New Track via ICY") + track = p.GetCurrentTrack() + if track != "New Track via ICY" { + t.Errorf("setCurrentTrack should override, got %q", track) + } +} + +func TestPlayerStateManagement(t *testing.T) { + p := NewPlayer(0) + + if p.GetState() != StateIdle { + t.Errorf("Initial state = %v, want StateIdle", p.GetState()) + } + + p.setState(StateBuffering) + if p.GetState() != StateBuffering { + t.Errorf("State after setState = %v, want StateBuffering", p.GetState()) + } + + p.setState(StatePlaying) + if p.GetState() != StatePlaying { + t.Errorf("State = %v, want StatePlaying", p.GetState()) + } +} + +func TestPlayerRetryInfo(t *testing.T) { + p := NewPlayer(0) + + current, max := p.GetRetryInfo() + if current != 0 || max != 0 { + t.Errorf("Initial retry info = (%d, %d), want (0, 0)", current, max) + } + + p.setRetryInfo(2, 5) + current, max = p.GetRetryInfo() + if current != 2 || max != 5 { + t.Errorf("Retry info = (%d, %d), want (2, 5)", current, max) + } +} + +func TestPlayerLastError(t *testing.T) { + p := NewPlayer(0) + + if p.GetLastError() != "" { + t.Errorf("Initial error = %q, want empty", p.GetLastError()) + } + + p.setLastError("Connection failed") + if p.GetLastError() != "Connection failed" { + t.Errorf("Error = %q, want 'Connection failed'", p.GetLastError()) + } +} + +func TestPlayerSessionDuration(t *testing.T) { + p := NewPlayer(0) + + if p.GetSessionDuration() != 0 { + t.Error("Initial session duration should be 0") + } + + p.startSession() + time.Sleep(10 * time.Millisecond) + + duration := p.GetSessionDuration() + if duration < 10*time.Millisecond { + t.Errorf("Session duration = %v, expected >= 10ms", duration) + } +} + +func TestPlayerBufferStats(t *testing.T) { + t.Run("no buffer", func(t *testing.T) { + p := NewPlayer(0) + + if p.GetBufferFillPercent() != 0 { + t.Error("Buffer fill should be 0 with no buffer") + } + if p.GetBufferHealth() != 0 { + t.Error("Buffer health should be 0 with no sample channel") + } + }) + + t.Run("with buffer", func(t *testing.T) { + p := NewPlayer(1) + + p.bufferMu.Lock() + p.writeIdx = int64(len(p.buffer) / 2) + p.bufferMu.Unlock() + + fill := p.GetBufferFillPercent() + if fill != 50 { + t.Errorf("Buffer fill = %d%%, want 50%%", fill) + } + }) +} + +func TestPlayerStreamInfo(t *testing.T) { + p := NewPlayer(0) + + info := p.GetStreamInfo() + if info.Format != "" || info.Bitrate != 0 { + t.Error("Initial stream info should be empty") + } + + p.setStreamInfo(StreamInfo{ + Format: "MP3", + Quality: "high", + Bitrate: 128, + SampleRate: 44100, + }) + + info = p.GetStreamInfo() + if info.Format != "MP3" || info.Bitrate != 128 { + t.Errorf("Stream info = %+v, expected MP3/128", info) + } +} + +func TestPlayerIsPlayingIsPaused(t *testing.T) { + p := NewPlayer(0) + + if p.IsPlaying() { + t.Error("New player should not be playing") + } + if p.IsPaused() { + t.Error("New player should not be paused") + } + + p.mu.Lock() + p.isPlaying = true + p.mu.Unlock() + + if !p.IsPlaying() { + t.Error("Player should be playing") + } + + p.mu.Lock() + p.isPaused = true + p.mu.Unlock() + + if p.IsPlaying() { + t.Error("Paused player should not report IsPlaying=true") + } + if !p.IsPaused() { + t.Error("Player should be paused") + } +} + +func TestFetchAndParsePLS(t *testing.T) { + plsContent := `[playlist] +NumberOfEntries=3 +File1=http://stream1.example.com/radio.mp3 +Title1=Stream 1 +File2=http://stream2.example.com/radio.mp3 +Title2=Stream 2 +File3=http://stream3.example.com/radio.mp3 +Title3=Stream 3 +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(plsContent)) + })) + defer server.Close() + + p := NewPlayer(0) + + ctx := context.Background() + urls, err := p.fetchAndParsePLS(ctx, server.URL) + if err != nil { + t.Fatalf("fetchAndParsePLS error: %v", err) + } + + if len(urls) != 3 { + t.Fatalf("Expected 3 URLs, got %d", len(urls)) + } + + expectedURLs := []string{ + "http://stream1.example.com/radio.mp3", + "http://stream2.example.com/radio.mp3", + "http://stream3.example.com/radio.mp3", + } + + for i, expected := range expectedURLs { + if urls[i] != expected { + t.Errorf("URL[%d] = %q, want %q", i, urls[i], expected) + } + } +} + +func TestFetchAndParsePLSEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("[playlist]\nNumberOfEntries=0\n")) + })) + defer server.Close() + + p := NewPlayer(0) + ctx := context.Background() + + _, err := p.fetchAndParsePLS(ctx, server.URL) + if err == nil { + t.Error("Expected error for empty PLS file") + } +} + +func TestFetchAndParsePLSInvalidServer(t *testing.T) { + p := NewPlayer(0) + ctx := context.Background() + + _, err := p.fetchAndParsePLS(ctx, "http://invalid.invalid.invalid/test.pls") + if err == nil { + t.Error("Expected error for invalid server") + } +} + +func TestContextReader(t *testing.T) { + t.Run("successful read", func(t *testing.T) { + reader := strings.NewReader("test data") + ctx := context.Background() + cr := &contextReader{reader: reader, ctx: ctx, timeout: time.Second} + + buf := make([]byte, 100) + n, err := cr.Read(buf) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if n != 9 { + t.Errorf("Read %d bytes, want 9", n) + } + if string(buf[:n]) != "test data" { + t.Errorf("Data = %q, want 'test data'", string(buf[:n])) + } + }) + + t.Run("timeout", func(t *testing.T) { + blockingReader := &blockingReader{} + ctx := context.Background() + cr := &contextReader{reader: blockingReader, ctx: ctx, timeout: 10 * time.Millisecond} + + buf := make([]byte, 100) + _, err := cr.Read(buf) + + if err == nil { + t.Error("Expected timeout error") + } + if !strings.Contains(err.Error(), "timeout") { + t.Errorf("Error = %q, expected to contain 'timeout'", err.Error()) + } + }) + + t.Run("context cancelled", func(t *testing.T) { + blockingReader := &blockingReader{} + ctx, cancel := context.WithCancel(context.Background()) + cr := &contextReader{reader: blockingReader, ctx: ctx, timeout: time.Hour} + + // Cancel context immediately + cancel() + + buf := make([]byte, 100) + _, err := cr.Read(buf) + + if err == nil { + t.Error("Expected context cancelled error") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("Error = %v, expected context.Canceled", err) + } + }) +} + +type blockingReader struct{} + +func (b *blockingReader) Read(p []byte) (int, error) { + time.Sleep(time.Hour) + return 0, nil +} + +func TestGetCurrentStation(t *testing.T) { + p := NewPlayer(0) + + if p.GetCurrentStation() != nil { + t.Error("Initial station should be nil") + } +} diff --git a/internal/service/station_service.go b/internal/service/station_service.go new file mode 100644 index 0000000..ccdf800 --- /dev/null +++ b/internal/service/station_service.go @@ -0,0 +1,234 @@ +// Package service provides the business logic layer for managing station data. +package service + +import ( + "context" + "image" + _ "image/jpeg" + _ "image/png" + "net/http" + "sort" + "strconv" + "sync" + "time" + + "github.com/glebovdev/somafm-cli/internal/api" + "github.com/glebovdev/somafm-cli/internal/cache" + "github.com/glebovdev/somafm-cli/internal/station" + "github.com/rs/zerolog/log" +) + +const imageLoadTimeout = 15 * time.Second + +// StationService manages station data, including fetching, caching, and periodic refresh. +type StationService struct { + apiClient *api.SomaFMClient + stations []station.Station + mu sync.RWMutex + imageCache *cache.Cache + refreshTicker *time.Ticker + stopRefresh chan struct{} + onRefresh func([]station.Station) +} + +// NewStationService creates a new StationService with the given API client. +func NewStationService(apiClient *api.SomaFMClient) *StationService { + imageCache, err := cache.NewCache() + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize image cache, images will not be cached") + } + + if imageCache != nil { + go func() { + if err := imageCache.CleanExpired(); err != nil { + log.Debug().Err(err).Msg("Failed to clean expired cache") + } + }() + } + + return &StationService{ + apiClient: apiClient, + imageCache: imageCache, + } +} + +func (s *StationService) GetStations() ([]station.Station, error) { + stations, err := s.apiClient.GetStations() + if err != nil { + return nil, err + } + + s.sortStationsByListeners(stations) + + s.mu.Lock() + s.stations = stations + s.mu.Unlock() + + return stations, nil +} + +func (s *StationService) GetCachedStations() []station.Station { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]station.Station, len(s.stations)) + copy(result, s.stations) + return result +} + +func (s *StationService) sortStationsByListeners(stations []station.Station) { + sort.Slice(stations, func(i, j int) bool { + listenersI, errI := strconv.Atoi(stations[i].Listeners) + listenersJ, errJ := strconv.Atoi(stations[j].Listeners) + + if errI != nil { + return false + } + if errJ != nil { + return true + } + + return listenersI > listenersJ + }) +} + +func (s *StationService) GetValidStationIDs() map[string]bool { + s.mu.RLock() + defer s.mu.RUnlock() + + validIDs := make(map[string]bool) + for _, st := range s.stations { + validIDs[st.ID] = true + } + return validIDs +} + +func (s *StationService) FindIndexByID(stationID string) int { + s.mu.RLock() + defer s.mu.RUnlock() + + for i, st := range s.stations { + if st.ID == stationID { + return i + } + } + return -1 +} + +func (s *StationService) StationCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.stations) +} + +// GetStation returns a copy of the station at the given index. +// Returns nil if the index is out of bounds. +// The returned station is a copy to prevent invalidation when the internal slice is refreshed. +func (s *StationService) GetStation(index int) *station.Station { + s.mu.RLock() + defer s.mu.RUnlock() + + if index < 0 || index >= len(s.stations) { + return nil + } + // Return a copy to prevent pointer invalidation on slice refresh + st := s.stations[index] + return &st +} + +func (s *StationService) LoadImage(url string) (image.Image, error) { + if s.imageCache != nil { + if img := s.imageCache.GetImage(url); img != nil { + log.Debug().Str("url", url).Msg("Image loaded from cache") + return img, nil + } + } + + ctx, cancel := context.WithTimeout(context.Background(), imageLoadTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + img, _, err := image.Decode(resp.Body) + if err != nil { + return nil, err + } + + if s.imageCache != nil { + go func() { + if err := s.imageCache.SaveImage(url, img); err != nil { + log.Debug().Err(err).Str("url", url).Msg("Failed to cache image") + } else { + log.Debug().Str("url", url).Msg("Image cached") + } + }() + } + + return img, nil +} + +func (s *StationService) GetCurrentTrackForStation(stationID string) (string, error) { + return s.apiClient.GetCurrentTrackForStation(stationID) +} + +func (s *StationService) StartPeriodicRefresh(interval time.Duration, callback func([]station.Station)) { + s.mu.Lock() + s.onRefresh = callback + s.stopRefresh = make(chan struct{}) + s.refreshTicker = time.NewTicker(interval) + s.mu.Unlock() + + go func() { + for { + select { + case <-s.refreshTicker.C: + s.refreshStationsInBackground() + case <-s.stopRefresh: + s.refreshTicker.Stop() + return + } + } + }() + + log.Debug().Dur("interval", interval).Msg("Started periodic station refresh") +} + +func (s *StationService) StopPeriodicRefresh() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.stopRefresh != nil { + close(s.stopRefresh) + s.stopRefresh = nil + } + log.Debug().Msg("Stopped periodic station refresh") +} + +func (s *StationService) refreshStationsInBackground() { + newStations, err := s.apiClient.GetStations() + if err != nil { + log.Warn().Err(err).Msg("Background refresh failed, keeping cached data") + return + } + + s.sortStationsByListeners(newStations) + + s.mu.Lock() + s.stations = newStations + callback := s.onRefresh + s.mu.Unlock() + + if callback != nil { + callback(newStations) + } + + log.Debug().Int("count", len(s.stations)).Msg("Station data refreshed in background") +} diff --git a/internal/service/station_service_test.go b/internal/service/station_service_test.go new file mode 100644 index 0000000..719f16e --- /dev/null +++ b/internal/service/station_service_test.go @@ -0,0 +1,459 @@ +package service + +import ( + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/glebovdev/somafm-cli/internal/cache" + "github.com/glebovdev/somafm-cli/internal/station" +) + +func TestSortStationsByListeners(t *testing.T) { + service := &StationService{} + + tests := []struct { + name string + stations []station.Station + expected []string // expected order of station IDs + }{ + { + name: "sort by listener count descending", + stations: []station.Station{ + {ID: "low", Listeners: "100"}, + {ID: "high", Listeners: "1000"}, + {ID: "mid", Listeners: "500"}, + }, + expected: []string{"high", "mid", "low"}, + }, + { + name: "handle invalid listener strings", + stations: []station.Station{ + {ID: "valid1", Listeners: "500"}, + {ID: "invalid", Listeners: "not-a-number"}, + {ID: "valid2", Listeners: "1000"}, + }, + expected: []string{"valid2", "valid1", "invalid"}, + }, + { + name: "handle empty listener strings", + stations: []station.Station{ + {ID: "valid", Listeners: "500"}, + {ID: "empty", Listeners: ""}, + }, + expected: []string{"valid", "empty"}, + }, + { + name: "handle single station", + stations: []station.Station{ + {ID: "only", Listeners: "100"}, + }, + expected: []string{"only"}, + }, + { + name: "handle empty list", + stations: []station.Station{}, + expected: []string{}, + }, + { + name: "handle equal listener counts", + stations: []station.Station{ + {ID: "first", Listeners: "500"}, + {ID: "second", Listeners: "500"}, + }, + expected: []string{"first", "second"}, + }, + { + name: "handle zero listeners", + stations: []station.Station{ + {ID: "zero", Listeners: "0"}, + {ID: "some", Listeners: "100"}, + }, + expected: []string{"some", "zero"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stations := make([]station.Station, len(tt.stations)) + copy(stations, tt.stations) + + service.sortStationsByListeners(stations) + + if len(stations) != len(tt.expected) { + t.Fatalf("sortStationsByListeners resulted in %d stations, want %d", + len(stations), len(tt.expected)) + } + + for i, st := range stations { + if st.ID != tt.expected[i] { + t.Errorf("stations[%d].ID = %q, want %q", i, st.ID, tt.expected[i]) + } + } + }) + } +} + +func TestGetValidStationIDs(t *testing.T) { + service := &StationService{ + stations: []station.Station{ + {ID: "groovesalad"}, + {ID: "dronezone"}, + {ID: "lush"}, + }, + } + + validIDs := service.GetValidStationIDs() + + if len(validIDs) != 3 { + t.Fatalf("GetValidStationIDs() returned %d IDs, want 3", len(validIDs)) + } + + expectedIDs := []string{"groovesalad", "dronezone", "lush"} + for _, id := range expectedIDs { + if !validIDs[id] { + t.Errorf("GetValidStationIDs() missing %q", id) + } + } + + if validIDs["nonexistent"] { + t.Error("GetValidStationIDs() should return false for nonexistent ID") + } +} + +func TestGetValidStationIDsEmpty(t *testing.T) { + service := &StationService{ + stations: []station.Station{}, + } + + validIDs := service.GetValidStationIDs() + + if len(validIDs) != 0 { + t.Errorf("GetValidStationIDs() with empty stations returned %d IDs, want 0", len(validIDs)) + } +} + +func TestFindIndexByID(t *testing.T) { + service := &StationService{ + stations: []station.Station{ + {ID: "groovesalad"}, + {ID: "dronezone"}, + {ID: "lush"}, + }, + } + + tests := []struct { + name string + id string + expected int + }{ + {"first station", "groovesalad", 0}, + {"middle station", "dronezone", 1}, + {"last station", "lush", 2}, + {"nonexistent station", "notfound", -1}, + {"empty string", "", -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.FindIndexByID(tt.id) + if result != tt.expected { + t.Errorf("FindIndexByID(%q) = %d, want %d", tt.id, result, tt.expected) + } + }) + } +} + +func TestFindIndexByIDEmptyList(t *testing.T) { + service := &StationService{ + stations: []station.Station{}, + } + + result := service.FindIndexByID("anything") + if result != -1 { + t.Errorf("FindIndexByID with empty list = %d, want -1", result) + } +} + +func TestGetStation(t *testing.T) { + stations := []station.Station{ + {ID: "groovesalad", Title: "Groove Salad"}, + {ID: "dronezone", Title: "Drone Zone"}, + {ID: "lush", Title: "Lush"}, + } + + service := &StationService{ + stations: stations, + } + + tests := []struct { + name string + index int + expectedID string + expectedNil bool + }{ + {"first station", 0, "groovesalad", false}, + {"middle station", 1, "dronezone", false}, + {"last station", 2, "lush", false}, + {"negative index", -1, "", true}, + {"index out of bounds", 3, "", true}, + {"index way out of bounds", 100, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.GetStation(tt.index) + + if tt.expectedNil { + if result != nil { + t.Errorf("GetStation(%d) = %v, want nil", tt.index, result) + } + } else { + if result == nil { + t.Fatalf("GetStation(%d) = nil, want station", tt.index) + } + if result.ID != tt.expectedID { + t.Errorf("GetStation(%d).ID = %q, want %q", tt.index, result.ID, tt.expectedID) + } + } + }) + } +} + +func TestGetStationEmptyList(t *testing.T) { + service := &StationService{ + stations: []station.Station{}, + } + + result := service.GetStation(0) + if result != nil { + t.Errorf("GetStation(0) with empty list = %v, want nil", result) + } +} + +func TestStationCount(t *testing.T) { + tests := []struct { + name string + stations []station.Station + expected int + }{ + { + name: "multiple stations", + stations: []station.Station{ + {ID: "a"}, {ID: "b"}, {ID: "c"}, + }, + expected: 3, + }, + { + name: "empty list", + stations: []station.Station{}, + expected: 0, + }, + { + name: "single station", + stations: []station.Station{ + {ID: "only"}, + }, + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &StationService{stations: tt.stations} + result := service.StationCount() + if result != tt.expected { + t.Errorf("StationCount() = %d, want %d", result, tt.expected) + } + }) + } +} + +func TestGetCachedStations(t *testing.T) { + expectedStations := []station.Station{ + {ID: "groovesalad", Title: "Groove Salad"}, + {ID: "dronezone", Title: "Drone Zone"}, + } + + service := &StationService{ + stations: expectedStations, + } + + result := service.GetCachedStations() + + if len(result) != len(expectedStations) { + t.Fatalf("GetCachedStations() returned %d stations, want %d", + len(result), len(expectedStations)) + } + + for i, st := range result { + if st.ID != expectedStations[i].ID { + t.Errorf("GetCachedStations()[%d].ID = %q, want %q", + i, st.ID, expectedStations[i].ID) + } + } +} + +func TestGetCachedStationsEmpty(t *testing.T) { + service := &StationService{ + stations: []station.Station{}, + } + + result := service.GetCachedStations() + + if len(result) != 0 { + t.Errorf("GetCachedStations() with empty list returned %d stations, want 0", + len(result)) + } +} + +func TestNewStationService(t *testing.T) { + service := NewStationService(nil) + + if service == nil { + t.Fatal("NewStationService() returned nil") + } + + if service.StationCount() != 0 { + t.Errorf("NewStationService() created service with %d stations, want 0", + service.StationCount()) + } +} + +func TestLoadImage(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _ = png.Encode(w, img) + })) + defer server.Close() + + service := &StationService{ + imageCache: nil, + } + + loadedImg, err := service.LoadImage(server.URL + "/test.png") + if err != nil { + t.Fatalf("LoadImage() error = %v", err) + } + + if loadedImg == nil { + t.Fatal("LoadImage() returned nil image") + } + + bounds := loadedImg.Bounds() + if bounds.Dx() != 100 || bounds.Dy() != 100 { + t.Errorf("LoadImage() returned image with size %dx%d, want 100x100", + bounds.Dx(), bounds.Dy()) + } +} + +func TestLoadImageInvalidURL(t *testing.T) { + service := &StationService{ + imageCache: nil, + } + + _, err := service.LoadImage("http://invalid.invalid.invalid/image.png") + if err == nil { + t.Error("LoadImage() should return error for invalid URL") + } +} + +func TestLoadImageWithCache(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 50, 50)) + for y := 0; y < 50; y++ { + for x := 0; x < 50; x++ { + img.Set(x, y, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + } + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "image/png") + _ = png.Encode(w, img) + })) + defer server.Close() + + imageCache, err := cache.NewCache() + if err != nil { + t.Skipf("Could not create cache: %v", err) + } + + service := &StationService{ + imageCache: imageCache, + } + + testURL := server.URL + "/test-cache.png" + + loadedImg1, err := service.LoadImage(testURL) + if err != nil { + t.Fatalf("First LoadImage() error = %v", err) + } + + if loadedImg1 == nil { + t.Fatal("First LoadImage() returned nil") + } + + if requestCount != 1 { + t.Errorf("Expected 1 HTTP request, got %d", requestCount) + } + + time.Sleep(100 * time.Millisecond) + + loadedImg2, err := service.LoadImage(testURL) + if err != nil { + t.Fatalf("Second LoadImage() error = %v", err) + } + + if loadedImg2 == nil { + t.Fatal("Second LoadImage() returned nil") + } + + if requestCount != 1 { + t.Logf("Got %d HTTP requests (cache may not be working)", requestCount) + } +} + +func TestLoadImageInvalidResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("not a valid image")) + })) + defer server.Close() + + service := &StationService{ + imageCache: nil, + } + + _, err := service.LoadImage(server.URL + "/test.png") + if err == nil { + t.Error("LoadImage() should return error for invalid image data") + } +} + +func TestStartAndStopPeriodicRefresh(t *testing.T) { + service := &StationService{} + + callback := func(stations []station.Station) {} + + service.StartPeriodicRefresh(50*time.Millisecond, callback) + time.Sleep(10 * time.Millisecond) + service.StopPeriodicRefresh() + service.StopPeriodicRefresh() +} + +func TestStopPeriodicRefreshBeforeStart(t *testing.T) { + service := &StationService{} + service.StopPeriodicRefresh() +} diff --git a/internal/station/station.go b/internal/station/station.go new file mode 100644 index 0000000..4617703 --- /dev/null +++ b/internal/station/station.go @@ -0,0 +1,67 @@ +// Package station defines the data structures for SomaFM radio stations. +package station + +// Playlist represents a streaming endpoint for a radio station. +type Playlist struct { + URL string `json:"url"` + Format string `json:"format"` // Audio format (e.g., "mp3", "aac") + Quality string `json:"quality"` // Quality level (e.g., "highest", "high") +} + +// Station represents a SomaFM radio station with its metadata and streaming options. +type Station struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + DJ string `json:"dj"` + DJMail string `json:"djmail"` + Genre string `json:"genre"` // Pipe-separated genre list + Image string `json:"image"` + LargeImage string `json:"largeimage"` + XLImage string `json:"xlimage"` + Twitter string `json:"twitter"` + Updated string `json:"updated"` + Playlists []Playlist `json:"playlists"` + Preroll []string `json:"preroll"` + Listeners string `json:"listeners"` + LastPlaying string `json:"lastPlaying"` +} + +// GetBestPlaylistURL returns the URL of the highest quality MP3 playlist. +// Falls back to the first available playlist if no MP3 "highest" quality is found. +func (s *Station) GetBestPlaylistURL() string { + for _, playlist := range s.Playlists { + if playlist.Format == "mp3" && playlist.Quality == "highest" { + return playlist.URL + } + } + if len(s.Playlists) > 0 { + return s.Playlists[0].URL + } + return "" +} + +// GetAllPlaylistURLs returns all playlist URLs sorted by preference: +// MP3 highest quality first, then other MP3, then other formats. +func (s *Station) GetAllPlaylistURLs() []string { + var mp3Highest, mp3Other, other []string + + for _, playlist := range s.Playlists { + if playlist.Format == "mp3" { + if playlist.Quality == "highest" { + mp3Highest = append(mp3Highest, playlist.URL) + } else { + mp3Other = append(mp3Other, playlist.URL) + } + } else { + other = append(other, playlist.URL) + } + } + + result := make([]string, 0, len(s.Playlists)) + result = append(result, mp3Highest...) + result = append(result, mp3Other...) + result = append(result, other...) + + return result +} diff --git a/internal/station/station_test.go b/internal/station/station_test.go new file mode 100644 index 0000000..03aa9b1 --- /dev/null +++ b/internal/station/station_test.go @@ -0,0 +1,186 @@ +package station + +import ( + "testing" +) + +func TestGetBestPlaylistURL(t *testing.T) { + tests := []struct { + name string + station Station + expected string + }{ + { + name: "Returns mp3 highest quality", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/low.pls", Format: "mp3", Quality: "low"}, + {URL: "http://example.com/high.pls", Format: "mp3", Quality: "highest"}, + {URL: "http://example.com/med.pls", Format: "aac", Quality: "high"}, + }, + }, + expected: "http://example.com/high.pls", + }, + { + name: "Returns first playlist when no mp3 highest", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/first.pls", Format: "aac", Quality: "high"}, + {URL: "http://example.com/second.pls", Format: "mp3", Quality: "low"}, + }, + }, + expected: "http://example.com/first.pls", + }, + { + name: "Returns empty string when no playlists", + station: Station{ + Playlists: []Playlist{}, + }, + expected: "", + }, + { + name: "Returns single playlist", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/only.pls", Format: "mp3", Quality: "high"}, + }, + }, + expected: "http://example.com/only.pls", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.station.GetBestPlaylistURL() + if result != tt.expected { + t.Errorf("GetBestPlaylistURL() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetAllPlaylistURLs(t *testing.T) { + tests := []struct { + name string + station Station + expected []string + }{ + { + name: "Prioritizes mp3 highest, then mp3 other, then other formats", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/aac-high.pls", Format: "aac", Quality: "high"}, + {URL: "http://example.com/mp3-low.pls", Format: "mp3", Quality: "low"}, + {URL: "http://example.com/mp3-highest.pls", Format: "mp3", Quality: "highest"}, + {URL: "http://example.com/ogg-med.pls", Format: "ogg", Quality: "medium"}, + }, + }, + expected: []string{ + "http://example.com/mp3-highest.pls", + "http://example.com/mp3-low.pls", + "http://example.com/aac-high.pls", + "http://example.com/ogg-med.pls", + }, + }, + { + name: "Multiple mp3 highest quality", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/mp3-highest-1.pls", Format: "mp3", Quality: "highest"}, + {URL: "http://example.com/mp3-highest-2.pls", Format: "mp3", Quality: "highest"}, + }, + }, + expected: []string{ + "http://example.com/mp3-highest-1.pls", + "http://example.com/mp3-highest-2.pls", + }, + }, + { + name: "Only non-mp3 formats", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/aac.pls", Format: "aac", Quality: "high"}, + {URL: "http://example.com/ogg.pls", Format: "ogg", Quality: "medium"}, + }, + }, + expected: []string{ + "http://example.com/aac.pls", + "http://example.com/ogg.pls", + }, + }, + { + name: "Empty playlists", + station: Station{ + Playlists: []Playlist{}, + }, + expected: []string{}, + }, + { + name: "Single playlist", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/only.pls", Format: "mp3", Quality: "high"}, + }, + }, + expected: []string{"http://example.com/only.pls"}, + }, + { + name: "Mp3 non-highest only", + station: Station{ + Playlists: []Playlist{ + {URL: "http://example.com/mp3-low.pls", Format: "mp3", Quality: "low"}, + {URL: "http://example.com/mp3-med.pls", Format: "mp3", Quality: "medium"}, + }, + }, + expected: []string{ + "http://example.com/mp3-low.pls", + "http://example.com/mp3-med.pls", + }, + }, + { + name: "Mixed with nil playlists field results in empty", + station: Station{ + Playlists: nil, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.station.GetAllPlaylistURLs() + + if len(result) != len(tt.expected) { + t.Fatalf("GetAllPlaylistURLs() returned %d items, want %d: got %v", len(result), len(tt.expected), result) + } + + for i, url := range result { + if url != tt.expected[i] { + t.Errorf("GetAllPlaylistURLs()[%d] = %q, want %q", i, url, tt.expected[i]) + } + } + }) + } +} + +func TestStationFields(t *testing.T) { + station := Station{ + ID: "groovesalad", + Title: "Groove Salad", + Description: "A nicely chilled plate of ambient/downtempo beats and grooves.", + DJ: "Rusty Hodge", + Genre: "ambient|electronica", + Listeners: "1234", + LastPlaying: "Artist - Song Title", + } + + if station.ID != "groovesalad" { + t.Errorf("Station.ID = %q, want %q", station.ID, "groovesalad") + } + if station.Title != "Groove Salad" { + t.Errorf("Station.Title = %q, want %q", station.Title, "Groove Salad") + } + if station.Listeners != "1234" { + t.Errorf("Station.Listeners = %q, want %q", station.Listeners, "1234") + } +} diff --git a/internal/ui/footer.go b/internal/ui/footer.go new file mode 100644 index 0000000..e7a96f9 --- /dev/null +++ b/internal/ui/footer.go @@ -0,0 +1,318 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/glebovdev/somafm-cli/internal/player" + "github.com/rivo/tview" +) + +type StatusRenderer struct { + player *player.Player + isMuted bool + animFrame int + maxAnimFrame int + tickCount int + ticksPerFrame int + + bufferHealth int + bufferTickCount int + bufferTicksPerUpdate int + + primaryColor string +} + +func NewStatusRenderer(p *player.Player) *StatusRenderer { + return &StatusRenderer{ + player: p, + maxAnimFrame: 4, + ticksPerFrame: 8, // Slow down animation (8 ticks per frame) + bufferTicksPerUpdate: 16, // Update buffer ~1 per second (16 * 60ms ≈ 960ms) + } +} + +func (s *StatusRenderer) SetMuted(muted bool) { + s.isMuted = muted +} + +func (s *StatusRenderer) SetPrimaryColor(color string) { + s.primaryColor = color +} + +func (s *StatusRenderer) AdvanceAnimation() { + s.tickCount++ + if s.tickCount >= s.ticksPerFrame { + s.tickCount = 0 + s.animFrame = (s.animFrame + 1) % s.maxAnimFrame + } + + s.bufferTickCount++ + if s.bufferTickCount >= s.bufferTicksPerUpdate { + s.bufferTickCount = 0 + if s.player != nil { + s.bufferHealth = s.player.GetBufferHealth() + } + } +} + +func (s *StatusRenderer) Render() string { + if s.player == nil { + return s.renderIdle() + } + + state := s.player.GetState() + + switch state { + case player.StateIdle: + return s.renderIdle() + case player.StateBuffering: + return s.renderBuffering() + case player.StatePlaying: + return s.renderPlaying() + case player.StatePaused: + return s.renderPaused() + case player.StateReconnecting: + return s.renderReconnecting() + case player.StateError: + return s.renderError() + default: + return s.renderIdle() + } +} + +func (s *StatusRenderer) renderIdle() string { + if s.isMuted { + return "○ IDLE │ [red]MUTED[-] │ Select a station" + } + return "○ IDLE │ Select a station" +} + +func (s *StatusRenderer) renderBuffering() string { + circles := []string{"◐", "◓", "◑", "◒"} + return fmt.Sprintf("%s BUFFERING", circles[s.animFrame]) +} + +func (s *StatusRenderer) renderPlaying() string { + dots := []string{"●", "◉", "○", "◉"} + dot := dots[s.animFrame] + + if s.primaryColor != "" { + dot = fmt.Sprintf("[%s]%s[-]", s.primaryColor, dot) + } + + parts := []string{dot + " LIVE"} + + if s.isMuted { + parts = append(parts, "[red]MUTED[-]") + } + + streamInfo := s.player.GetStreamInfo() + if streamInfo.Format != "" { + sampleRateKHz := float64(streamInfo.SampleRate) / 1000.0 + parts = append(parts, fmt.Sprintf("%s %s %dk %.1fkHz", + streamInfo.Format, + qualityShort(streamInfo.Quality), + streamInfo.Bitrate, + sampleRateKHz)) + } + + parts = append(parts, s.formatBufferHealth(s.bufferHealth)) + + return joinParts(parts) +} + +func (s *StatusRenderer) renderPaused() string { + parts := []string{"⏸︎ PAUSED"} + + if s.isMuted { + parts = append(parts, "[red]MUTED[-]") + } + + streamInfo := s.player.GetStreamInfo() + if streamInfo.Format != "" { + sampleRateKHz := float64(streamInfo.SampleRate) / 1000.0 + parts = append(parts, fmt.Sprintf("%s %s %dk %.1fkHz", + streamInfo.Format, + qualityShort(streamInfo.Quality), + streamInfo.Bitrate, + sampleRateKHz)) + } + + return joinParts(parts) +} + +func (s *StatusRenderer) renderReconnecting() string { + current, max := s.player.GetRetryInfo() + return fmt.Sprintf("↻ RETRY %d/%d", current, max) +} + +func (s *StatusRenderer) renderError() string { + errMsg := s.player.GetLastError() + if errMsg == "" { + errMsg = "ERROR" + } + return fmt.Sprintf("✗ %s", errMsg) +} + +func (s *StatusRenderer) formatBufferHealth(percent int) string { + signalBars := []string{"▁", "▂", "▃", "▅", "▇"} + const numBars = 5 + + filled := (percent * numBars) / 100 + if filled > numBars { + filled = numBars + } + + bar := "" + for i := 0; i < numBars; i++ { + if i < filled { + bar += signalBars[i] + } else { + bar += "▁" + } + } + + return bar +} + +func qualityShort(quality string) string { + switch quality { + case "highest", "high": + return "HQ" + case "medium": + return "MQ" + case "low": + return "LQ" + default: + return "" + } +} + +func joinParts(parts []string) string { + if len(parts) == 0 { + return "" + } + result := parts[0] + for i := 1; i < len(parts); i++ { + result += " │ " + parts[i] + } + return result +} + +func (ui *UI) getPlaybackHint(keyColor string) string { + state := ui.player.GetState() + + switch state { + case player.StatePaused: + return fmt.Sprintf("[%s]Enter[-] play [%s]Space[-] resume", keyColor, keyColor) + case player.StatePlaying, player.StateBuffering, player.StateReconnecting: + return fmt.Sprintf("[%s]Enter[-] play [%s]Space[-] pause", keyColor, keyColor) + default: + return fmt.Sprintf("[%s]Space[-] play", keyColor) + } +} + +func (ui *UI) getHelpText() string { + keyColor := ui.colors.helpHotkey.String() + playbackHint := ui.getPlaybackHint(keyColor) + + muteText := "mute" + if ui.isMuted { + muteText = "unmute" + } + + return fmt.Sprintf(" %s [%s]+/-[-] vol [%s]m[-] %s [%s]?[-] help [%s]a[-] about [%s]q[-] quit ", + playbackHint, keyColor, keyColor, muteText, keyColor, keyColor, keyColor) +} + +func (ui *UI) handleFooterResize(width int) { + isWide := width >= FooterBreakpoint + wasWide := ui.lastFooterWidth >= FooterBreakpoint + + if ui.lastFooterWidth > 0 && isWide != wasWide && ui.contentLayout != nil { + newHeight := FooterHeightWide + if !isWide { + newHeight = FooterHeightNarrow + } + ui.contentLayout.ResizeItem(ui.helpPanel, newHeight, 0) + } + ui.lastFooterWidth = width +} + +func (ui *UI) drawWideFooter(screen tcell.Screen, x, y, width, height int, helpText, statusText string) { + helpWidth := width / 2 + statusWidth := width - helpWidth + + for row := y; row < y+height; row++ { + for col := x; col < x+helpWidth; col++ { + screen.SetContent(col, row, ' ', nil, tcell.StyleDefault.Background(ui.colors.helpBackground)) + } + } + + for row := y; row < y+height; row++ { + for col := x + helpWidth; col < x+width; col++ { + screen.SetContent(col, row, ' ', nil, tcell.StyleDefault.Background(ui.colors.background)) + } + } + + centerY := y + height/2 + tview.Print(screen, helpText, x, centerY, helpWidth, tview.AlignCenter, ui.colors.helpForeground) + tview.Print(screen, statusText, x+helpWidth, centerY, statusWidth-2, tview.AlignRight, ui.colors.foreground) +} + +func (ui *UI) drawNarrowFooter(screen tcell.Screen, x, y, width, height int, helpText, statusText string) { + helpHeight := height / 2 + if helpHeight < 1 { + helpHeight = 1 + } + statusHeight := height - helpHeight + helpBoxEnd := y + helpHeight + + for row := y; row < helpBoxEnd; row++ { + for col := x; col < x+width; col++ { + screen.SetContent(col, row, ' ', nil, tcell.StyleDefault.Background(ui.colors.helpBackground)) + } + } + + for row := helpBoxEnd; row < y+height; row++ { + for col := x; col < x+width; col++ { + screen.SetContent(col, row, ' ', nil, tcell.StyleDefault.Background(ui.colors.background)) + } + } + + helpTextY := y + helpHeight/2 + tview.Print(screen, helpText, x, helpTextY, width, tview.AlignCenter, ui.colors.helpForeground) + + if statusHeight > 0 { + statusTextY := helpBoxEnd + statusHeight/2 + tview.Print(screen, statusText, x, statusTextY, width-2, tview.AlignRight, ui.colors.foreground) + } +} + +func (ui *UI) createFooter() *tview.Box { + box := tview.NewBox().SetBackgroundColor(ui.colors.background) + + box.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { + ui.handleFooterResize(width) + + helpText := ui.getHelpText() + statusText := " " + ui.statusRenderer.Render() + " " + + isWide := width >= FooterBreakpoint + usedHeight := height + if isWide && height > FooterHeightWide { + usedHeight = FooterHeightWide + } + + if isWide { + ui.drawWideFooter(screen, x, y, width, usedHeight, helpText, statusText) + } else { + ui.drawNarrowFooter(screen, x, y, width, height, helpText, statusText) + } + + return x, y, width, height + }) + + return box +} diff --git a/internal/ui/modals.go b/internal/ui/modals.go new file mode 100644 index 0000000..1287d8c --- /dev/null +++ b/internal/ui/modals.go @@ -0,0 +1,412 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/glebovdev/somafm-cli/internal/config" + "github.com/rivo/tview" +) + +func extractErrorReason(err error) string { + errStr := err.Error() + + // Common network errors + if strings.Contains(errStr, "no such host") { + return "Unable to connect to server.\nPlease check your internet connection." + } + if strings.Contains(errStr, "connection refused") { + return "Connection refused by server.\nThe service may be temporarily unavailable." + } + if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "deadline exceeded") { + return "Connection timed out.\nPlease check your internet connection." + } + if strings.Contains(errStr, "network is unreachable") { + return "Network is unreachable.\nPlease check your internet connection." + } + if strings.Contains(errStr, "status 401") { + return "Stream access denied (401).\nTrying alternative servers..." + } + if strings.Contains(errStr, "status 403") { + return "Stream access forbidden (403)." + } + if strings.Contains(errStr, "status 404") { + return "Stream not found (404)." + } + + if idx := strings.Index(errStr, ": dial"); idx > 0 { + return errStr[:idx] + } + if len(errStr) > 100 { + return errStr[:100] + "..." + } + return errStr +} + +func (ui *UI) showErrorModal(title, message string, onDismiss func()) { + doDismiss := func() { + ui.pages.RemovePage("modal") + ui.app.SetFocus(ui.stationList) + if onDismiss != nil { + onDismiss() + } + } + + messageView := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText(fmt.Sprintf("\n[::b]%s[::-]\n\n%s", title, message)) + messageView.SetTextColor(ui.colors.foreground) + messageView.SetBackgroundColor(ui.colors.modalBackground) + + hintView := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[::d]Press Enter or Esc to continue[::-]") + hintView.SetTextColor(tcell.ColorDarkGray) + hintView.SetBackgroundColor(ui.colors.modalBackground) + + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(messageView, 0, 1, false). + AddItem(hintView, 1, 0, false). + AddItem(nil, 1, 0, false) + content.SetBackgroundColor(ui.colors.modalBackground) + + frame := tview.NewFrame(content). + SetBorders(0, 0, 1, 1, 1, 1) + frame.SetBorder(true). + SetBorderColor(ui.colors.highlight). + SetBackgroundColor(ui.colors.modalBackground). + SetTitle(" Error "). + SetTitleColor(ui.colors.highlight). + SetTitleAlign(tview.AlignCenter) + + modalWidth := 50 + modalHeight := 10 + + lines := strings.Count(message, "\n") + 1 + if lines > 2 { + modalHeight += lines - 2 + } + if modalHeight > 15 { + modalHeight = 15 + } + + modal := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(frame, modalHeight, 0, true). + AddItem(nil, 0, 1, false), + modalWidth, 0, true). + AddItem(nil, 0, 1, false) + modal.SetBackgroundColor(ui.colors.background) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter, tcell.KeyEscape: + doDismiss() + return nil + } + return event + }) + + ui.pages.AddPage("modal", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showError(message string) { + friendlyMsg := message + if strings.HasPrefix(message, "Failed to play station: ") { + errPart := strings.TrimPrefix(message, "Failed to play station: ") + if strings.Contains(errPart, "no such host") { + friendlyMsg = "Unable to connect to stream server.\nPlease check your internet connection." + } else if strings.Contains(errPart, "connection refused") { + friendlyMsg = "Connection refused by stream server." + } else if strings.Contains(errPart, "timeout") { + friendlyMsg = "Connection timed out." + } else if strings.Contains(errPart, "network read error") { + friendlyMsg = "Network connection lost.\nPlease check your internet connection." + } else { + if len(errPart) > 80 { + friendlyMsg = "Unable to play station.\nPlease try again later." + } else { + friendlyMsg = errPart + } + } + } + + ui.showErrorModal("Playback Error", friendlyMsg, nil) +} + +func (ui *UI) showHelpModal() { + keyColor := ui.colors.helpHotkey.String() + + configPath, _ := config.GetConfigPath() + + helpText := fmt.Sprintf(`[::b]KEYBOARD SHORTCUTS[::-] + +[%s]PLAYBACK[-] + [%s]Enter[-] Play selected station + [%s]Space[-] Pause / Resume + [%s]<[-] Previous station + [%s]>[-] Next station + [%s]r[-] Random station + +[%s]VOLUME[-] + [%s]+[-] / [%s]-[-] Volume up / down + [%s]←[-] / [%s]→[-] Volume up / down + [%s]m[-] Mute / Unmute + +[%s]STATIONS[-] + [%s]↑[-] / [%s]↓[-] Navigate list + [%s]f[-] Toggle favorite + +[%s]APPLICATION[-] + [%s]?[-] Show this help + [%s]a[-] About %s + [%s]q[-] / [%s]Esc[-] Quit + +[%s]CONFIG[-]: %s`, + keyColor, + keyColor, keyColor, keyColor, keyColor, keyColor, + keyColor, + keyColor, keyColor, keyColor, keyColor, keyColor, + keyColor, + keyColor, keyColor, keyColor, + keyColor, + keyColor, keyColor, config.AppName, keyColor, keyColor, + keyColor, configPath) + + ui.showInfoModal("Help", helpText) +} + +func (ui *UI) showAboutModal() { + doDismiss := func() { + ui.pages.RemovePage("modal") + ui.app.SetFocus(ui.stationList) + } + + linkColor := "skyblue" + dimColor := "gray" + + aboutText := fmt.Sprintf(`[::b]%s[::-] +[%s]%s[-] + +Version: %s +Author: %s ([%s:::%s]%s[-:::-]) +Project: [%s:::%s]%s[-:::-] +License: MIT + +─────────────────────────────────────────── + +[%s]Radio content from[-] [::b]SomaFM[::-] +Listener-supported • [%s:::%s]%s[-:::-]`, + config.AppName, + dimColor, config.AppTagline, + config.AppVersion, + config.AppAuthor, linkColor, config.AppAuthorURL, config.AppAuthorURLShort, + linkColor, config.AppProjectURL, config.AppProjectShort, + dimColor, + linkColor, config.AppDonateURL, config.AppDonateShort) + + messageView := tview.NewTextView(). + SetTextAlign(tview.AlignLeft). + SetDynamicColors(true). + SetText("\n" + aboutText) + messageView.SetTextColor(ui.colors.foreground) + messageView.SetBackgroundColor(ui.colors.modalBackground) + + hintView := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[::d]Press any key to close[::-]") + hintView.SetTextColor(tcell.ColorDarkGray) + hintView.SetBackgroundColor(ui.colors.modalBackground) + + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(messageView, 0, 1, false). + AddItem(nil, 2, 0, false). + AddItem(hintView, 1, 0, false). + AddItem(nil, 1, 0, false) + content.SetBackgroundColor(ui.colors.modalBackground) + + frame := tview.NewFrame(content). + SetBorders(1, 0, 1, 1, 2, 2) + frame.SetBorder(true). + SetBorderColor(ui.colors.borders). + SetBackgroundColor(ui.colors.modalBackground). + SetTitle(" About "). + SetTitleColor(ui.colors.highlight). + SetTitleAlign(tview.AlignCenter) + + modalWidth := 50 + modalHeight := 20 + + modal := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(frame, modalHeight, 0, true). + AddItem(nil, 0, 1, false), + modalWidth, 0, true). + AddItem(nil, 0, 1, false) + modal.SetBackgroundColor(ui.colors.background) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + doDismiss() + return nil + }) + + ui.pages.AddPage("modal", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showInfoModal(title, message string) { + doDismiss := func() { + ui.pages.RemovePage("modal") + ui.app.SetFocus(ui.stationList) + } + + messageView := tview.NewTextView(). + SetTextAlign(tview.AlignLeft). + SetDynamicColors(true). + SetWordWrap(true). + SetText("\n" + message) + messageView.SetTextColor(ui.colors.foreground) + messageView.SetBackgroundColor(ui.colors.modalBackground) + + hintView := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[::d]Press any key to close[::-]") + hintView.SetTextColor(tcell.ColorDarkGray) + hintView.SetBackgroundColor(ui.colors.modalBackground) + + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(messageView, 0, 1, false). + AddItem(nil, 2, 0, false). + AddItem(hintView, 1, 0, false). + AddItem(nil, 1, 0, false) + content.SetBackgroundColor(ui.colors.modalBackground) + + frame := tview.NewFrame(content). + SetBorders(1, 0, 1, 1, 2, 2) + frame.SetBorder(true). + SetBorderColor(ui.colors.borders). + SetBackgroundColor(ui.colors.modalBackground). + SetTitle(" " + title + " "). + SetTitleColor(ui.colors.highlight). + SetTitleAlign(tview.AlignCenter) + + lines := strings.Count(message, "\n") + 1 + modalWidth := 45 + modalHeight := lines + 10 + if modalHeight > 38 { + modalHeight = 38 + } + + modal := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(frame, modalHeight, 0, true). + AddItem(nil, 0, 1, false), + modalWidth, 0, true). + AddItem(nil, 0, 1, false) + modal.SetBackgroundColor(ui.colors.background) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + doDismiss() + return nil + }) + + ui.pages.AddPage("modal", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showInitialErrorScreen(title, message string, onRetry, onQuit func()) { + content := fmt.Sprintf("[::b]%s[::-]\n\n%s", title, message) + + textView := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText(content) + textView.SetTextColor(ui.colors.foreground) + textView.SetBackgroundColor(ui.colors.modalBackground) + + helpText := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[::d]Press [::b]R[::d] to retry • Press [::b]Q[::d] to quit[::-]") + helpText.SetTextColor(ui.colors.foreground) + helpText.SetBackgroundColor(ui.colors.background) + + frame := tview.NewFrame(textView). + SetBorders(2, 2, 2, 2, 2, 2) + frame.SetBorder(true). + SetBorderColor(ui.colors.highlight). + SetBackgroundColor(ui.colors.modalBackground). + SetTitle(" Connection Error "). + SetTitleColor(ui.colors.highlight) + + layout := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(frame, 60, 1, true). + AddItem(nil, 0, 1, false), 10, 1, true). + AddItem(helpText, 2, 0, false). + AddItem(nil, 0, 1, false) + layout.SetBackgroundColor(ui.colors.background) + + layout.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'r', 'R': + if onRetry != nil { + onRetry() + } + return nil + case 'q', 'Q': + if onQuit != nil { + onQuit() + } + return nil + } + case tcell.KeyEscape: + if onQuit != nil { + onQuit() + } + return nil + } + return event + }) + + ui.app.SetRoot(layout, true) + ui.app.SetFocus(layout) +} + +func (ui *UI) handleInitialError(err error) { + friendlyMsg := extractErrorReason(err) + + ui.showInitialErrorScreen( + "Unable to Load Stations", + friendlyMsg, + func() { // onRetry + ui.app.SetRoot(ui.loadingScreen, true) + go func() { + if err := ui.fetchStationsAndInitUI(); err != nil { + ui.app.QueueUpdateDraw(func() { + ui.handleInitialError(err) + }) + } + }() + }, + func() { // onQuit + ui.app.Stop() + }, + ) +} diff --git a/internal/ui/stations.go b/internal/ui/stations.go new file mode 100644 index 0000000..b64eb42 --- /dev/null +++ b/internal/ui/stations.go @@ -0,0 +1,295 @@ +package ui + +import ( + "fmt" + "math/rand/v2" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" +) + +func (ui *UI) createStationListTable() *tview.Table { + table := tview.NewTable(). + SetBorders(false). + SetSeparator(' '). + SetSelectable(true, false). + SetFixed(1, 0) + + table.SetBorder(true). + SetTitle(fmt.Sprintf("Stations (%d)", ui.stationService.StationCount())). + SetBorderColor(ui.colors.borders). + SetTitleColor(ui.colors.foreground). + SetBackgroundColor(ui.colors.background). + SetBorderPadding(1, 0, 1, 1) + + table.SetSelectedStyle(tcell.StyleDefault. + Foreground(ui.colors.background). + Background(ui.colors.highlight)) + + table.SetCell(0, 0, tview.NewTableCell(" "). + SetTextColor(ui.colors.stationListHeaderForeground). + SetBackgroundColor(ui.colors.stationListHeaderBackground). + SetMaxWidth(2). + SetSelectable(false)) + + table.SetCell(0, 1, tview.NewTableCell(" "). + SetTextColor(ui.colors.stationListHeaderForeground). + SetBackgroundColor(ui.colors.stationListHeaderBackground). + SetMaxWidth(2). + SetSelectable(false)) + + table.SetCell(0, 2, tview.NewTableCell("Name"). + SetTextColor(ui.colors.stationListHeaderForeground). + SetBackgroundColor(ui.colors.stationListHeaderBackground). + SetExpansion(1). + SetSelectable(false)) + + table.SetCell(0, 3, tview.NewTableCell("Genre"). + SetTextColor(ui.colors.stationListHeaderForeground). + SetBackgroundColor(ui.colors.stationListHeaderBackground). + SetExpansion(1). + SetSelectable(false)) + + table.SetCell(0, 4, tview.NewTableCell("Listeners"). + SetTextColor(ui.colors.stationListHeaderForeground). + SetBackgroundColor(ui.colors.stationListHeaderBackground). + SetAlign(tview.AlignRight). + SetSelectable(false)) + + stationCount := ui.stationService.StationCount() + for i := 0; i < stationCount; i++ { + ui.setStationRow(table, i+1, i) + } + + // Track selected station ID for preserving selection after refresh + table.SetSelectionChangedFunc(func(row, column int) { + count := ui.stationService.StationCount() + if row > 0 && row <= count { + if s := ui.stationService.GetStation(row - 1); s != nil { + ui.selectedStationID = s.ID + } + } + }) + + return table +} + +func (ui *UI) setStationRow(table *tview.Table, row int, stationIndex int) { + s := ui.stationService.GetStation(stationIndex) + if s == nil { + return + } + + favIcon := " " + if ui.config.IsFavorite(s.ID) { + favIcon = "★" + } + table.SetCell(row, 0, tview.NewTableCell(favIcon). + SetTextColor(ui.colors.foreground). + SetMaxWidth(2)) + + playIcon := " " + if stationIndex == ui.playingIndex { + if ui.player.IsPaused() { + playIcon = "⏸" + } else { + playIcon = "➤" + } + } + table.SetCell(row, 1, tview.NewTableCell(playIcon). + SetTextColor(ui.colors.foreground). + SetMaxWidth(2)) + + table.SetCell(row, 2, tview.NewTableCell(s.Title). + SetTextColor(ui.colors.foreground). + SetMaxWidth(35). + SetExpansion(2)) + + genreText := strings.ReplaceAll(s.Genre, "|", ", ") + table.SetCell(row, 3, tview.NewTableCell(genreText). + SetTextColor(ui.colors.foreground). + SetMaxWidth(27). + SetExpansion(1)) + + table.SetCell(row, 4, tview.NewTableCell(s.Listeners). + SetTextColor(ui.colors.foreground). + SetAlign(tview.AlignRight)) +} + +func (ui *UI) nextStation() { + stationCount := ui.stationService.StationCount() + if stationCount == 0 { + return + } + + row, _ := ui.stationList.GetSelection() + currentIndex := row - 1 + nextIndex := (currentIndex + 1) % stationCount + ui.stationList.Select(nextIndex+1, 0) + ui.onStationSelected(nextIndex) +} + +func (ui *UI) prevStation() { + stationCount := ui.stationService.StationCount() + if stationCount == 0 { + return + } + + row, _ := ui.stationList.GetSelection() + currentIndex := row - 1 + prevIndex := currentIndex - 1 + if prevIndex < 0 { + prevIndex = stationCount - 1 + } + ui.stationList.Select(prevIndex+1, 0) + ui.onStationSelected(prevIndex) +} + +func (ui *UI) randomStation() { + stationCount := ui.stationService.StationCount() + if stationCount == 0 { + return + } + + randomIndex := rand.IntN(stationCount) + ui.stationList.Select(randomIndex+1, 0) + ui.onStationSelected(randomIndex) +} + +func (ui *UI) selectAndShowStation(index int) { + stationCount := ui.stationService.StationCount() + if stationCount == 0 || index < 0 || index >= stationCount { + return + } + + ui.currentStation = ui.stationService.GetStation(index) + + ui.stationList.Select(index+1, 0) + + ui.playerPanel.Clear() + contentPanel := ui.createContentPanel() + ui.playerPanel.AddItem(contentPanel, 0, 1, false) + + ui.updateLogoPanel(ui.currentStation) + + log.Debug().Msgf("Showing station info (without playing): %s", ui.currentStation.Title) +} + +func (ui *UI) selectAndShowStationByID(stationID string) bool { + index := ui.stationService.FindIndexByID(stationID) + if index < 0 { + log.Debug().Msgf("Last played station '%s' not found in station list", stationID) + return false + } + + ui.selectAndShowStation(index) + if s := ui.stationService.GetStation(index); s != nil { + log.Debug().Msgf("Auto-selected last played station: %s", s.Title) + } + return true +} + +func (ui *UI) toggleFavorite() { + row, _ := ui.stationList.GetSelection() + stationCount := ui.stationService.StationCount() + if row <= 0 || row > stationCount { + return + } + + stationIndex := row - 1 + selectedStation := ui.stationService.GetStation(stationIndex) + if selectedStation == nil { + return + } + + ui.config.ToggleFavorite(selectedStation.ID) + + favCell := ui.stationList.GetCell(row, 0) + if favCell != nil { + if ui.config.IsFavorite(selectedStation.ID) { + favCell.SetText("★") + } else { + favCell.SetText(" ") + } + } + + go func() { + if err := ui.config.Save(); err != nil { + log.Error().Err(err).Msg("Failed to save config") + } + }() + + log.Debug().Msgf("Toggled favorite for station: %s", selectedStation.Title) +} + +func (ui *UI) refreshStationTable() { + stationCount := ui.stationService.StationCount() + + // Stations may have been re-sorted, so update index by ID + if ui.playingStationID != "" { + newIndex := ui.stationService.FindIndexByID(ui.playingStationID) + if newIndex >= 0 { + ui.playingIndex = newIndex + } + } + + for i := 0; i < stationCount; i++ { + ui.setStationRow(ui.stationList, i+1, i) + } + + if ui.selectedStationID != "" { + newIndex := ui.stationService.FindIndexByID(ui.selectedStationID) + if newIndex >= 0 { + ui.stationList.Select(newIndex+1, 0) + } + } + + ui.stationList.SetTitle(fmt.Sprintf("Stations (%d)", stationCount)) + + log.Debug().Int("count", stationCount).Msg("Station table refreshed") +} + +func (ui *UI) updateStationListPlayingIndicator() { + stationCount := ui.stationService.StationCount() + if ui.playingIndex < 0 || ui.playingIndex >= stationCount { + return + } + + if !ui.player.IsPlaying() && !ui.player.IsPaused() { + return + } + + row := ui.playingIndex + 1 + s := ui.stationService.GetStation(ui.playingIndex) + if s == nil { + return + } + + playCell := ui.stationList.GetCell(row, 1) + if playCell != nil { + if ui.player.IsPaused() { + playCell.SetText("⏸") + } else { + playCell.SetText("➤") + } + } + + nameCell := ui.stationList.GetCell(row, 2) + if nameCell == nil { + return + } + + name := s.Title + indicator := ui.getPlayingIndicator() + + const maxNameWidth = 35 + maxLen := maxNameWidth - len(indicator) - 1 + if len(name) > maxLen { + name = name[:maxLen-3] + "..." + } + + nameText := name + " " + indicator + nameCell.SetText(nameText) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..19a87a7 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,722 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/glebovdev/somafm-cli/internal/config" + "github.com/glebovdev/somafm-cli/internal/player" + "github.com/glebovdev/somafm-cli/internal/service" + "github.com/glebovdev/somafm-cli/internal/station" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" +) + +const ( + VolumeStep = 5 + HeaderHeight = 3 + FooterHeightWide = 3 // Wide: 1 row with padding (top + text + bottom) + FooterHeightNarrow = 6 // Narrow: 2 rows × 3 lines each + CoverWidth = 26 + CoverHeight = 12 + PlayerPanelHeight = 12 + FooterBreakpoint = 130 // Width threshold for responsive footer +) + +type UI struct { + app *tview.Application + stationService *service.StationService + player *player.Player + currentStation *station.Station + stationList *tview.Table + helpPanel *tview.Box + contentLayout *tview.Flex + playerPanel *tview.Flex + currentTrackView *tview.TextView + logoPanel *tview.Image + volumeView *tview.Flex + mainLayout *tview.Flex + loadingScreen *tview.Flex + progressBar *tview.TextView + loadingText *tview.TextView + pages *tview.Pages + stopUpdates chan struct{} + playingIndex int + playingStationID string + selectedStationID string + currentVolume int + isMuted bool + config *config.Config + startRandom bool + lastFooterWidth int // Track width to detect layout changes + mu sync.Mutex + animationFrame int + playingSpinner *PlayingSpinner + statusRenderer *StatusRenderer + colors struct { + background tcell.Color + foreground tcell.Color + borders tcell.Color + highlight tcell.Color + headerBackground tcell.Color + stationListHeaderBackground tcell.Color + stationListHeaderForeground tcell.Color + helpBackground tcell.Color + helpForeground tcell.Color + helpHotkey tcell.Color + genreTagBackground tcell.Color + modalBackground tcell.Color + } +} + +func NewUI(player *player.Player, stationService *service.StationService, startRandom bool) *UI { + cfg, err := config.Load() + if err != nil { + log.Warn().Err(err).Msg("Failed to load config, using defaults") + cfg = config.DefaultConfig() + } + + ui := &UI{ + app: tview.NewApplication(), + player: player, + stationService: stationService, + stopUpdates: make(chan struct{}), + playingIndex: -1, + currentVolume: cfg.Volume, + isMuted: false, + config: cfg, + startRandom: startRandom, + } + + ui.colors.background = config.GetColor(cfg.Theme.Background) + ui.colors.foreground = config.GetColor(cfg.Theme.Foreground) + ui.colors.borders = config.GetColor(cfg.Theme.Borders) + ui.colors.highlight = config.GetColor(cfg.Theme.Highlight) + ui.colors.headerBackground = config.GetColor(cfg.Theme.HeaderBackground) + ui.colors.stationListHeaderBackground = config.GetColor(cfg.Theme.StationListHeaderBackground) + ui.colors.stationListHeaderForeground = config.GetColor(cfg.Theme.StationListHeaderForeground) + ui.colors.helpBackground = config.GetColor(cfg.Theme.HelpBackground) + ui.colors.helpForeground = config.GetColor(cfg.Theme.HelpForeground) + ui.colors.helpHotkey = config.GetColor(cfg.Theme.HelpHotkey) + ui.colors.genreTagBackground = config.GetColor(cfg.Theme.GenreTagBackground) + ui.colors.modalBackground = config.GetColor(cfg.Theme.ModalBackground) + + player.SetVolume(cfg.Volume) + log.Debug().Msgf("Loaded volume from config: %d%%", cfg.Volume) + + ui.statusRenderer = NewStatusRenderer(player) + ui.statusRenderer.SetPrimaryColor(ui.colors.highlight.String()) + + return ui +} + +func (ui *UI) SaveConfig() { + ui.mu.Lock() + if !ui.isMuted { + ui.config.Volume = ui.currentVolume + } + if ui.currentStation != nil { + ui.config.LastStation = ui.currentStation.ID + } + ui.mu.Unlock() + + if err := ui.config.Save(); err != nil { + log.Error().Err(err).Msg("Failed to save config") + } +} + +func (ui *UI) safeCloseChannel() { + ui.mu.Lock() + defer ui.mu.Unlock() + + if ui.stopUpdates != nil { + select { + case <-ui.stopUpdates: + // Already closed + default: + close(ui.stopUpdates) + } + ui.stopUpdates = nil + } +} + +func (ui *UI) recreateStopChannel() { + ui.mu.Lock() + defer ui.mu.Unlock() + ui.stopUpdates = make(chan struct{}) +} + +func (ui *UI) stop() { + ui.stationService.StopPeriodicRefresh() + ui.player.Stop() + ui.safeCloseChannel() + ui.app.Stop() +} + +// Shutdown stops the UI gracefully from external callers (e.g., signal handlers). +func (ui *UI) Shutdown() { + ui.app.QueueUpdateDraw(func() { + ui.stop() + }) +} + +func (ui *UI) Run() error { + ui.setupLoadingScreen() + ui.app.SetRoot(ui.loadingScreen, true) + + ui.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + screen.SetStyle(tcell.StyleDefault.Background(ui.colors.background)) + screen.Clear() + return false + }) + + go func() { + if err := ui.fetchStationsAndInitUI(); err != nil { + ui.app.QueueUpdateDraw(func() { + ui.handleInitialError(err) + }) + } + }() + + return ui.app.Run() +} + +func (ui *UI) setupLoadingScreen() { + ui.progressBar = tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("Loading: 0%") + + ui.progressBar.SetTextColor(ui.colors.foreground). + SetBackgroundColor(ui.colors.background) + + ui.loadingText = tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetText("Initializing...") + ui.loadingText.SetTextColor(ui.colors.foreground). + SetBackgroundColor(ui.colors.background) + + ui.loadingScreen = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(ui.loadingText, 0, 1, false). + AddItem(nil, 0, 1, false), 3, 1, false). + AddItem(ui.progressBar, 1, 1, false). + AddItem(nil, 0, 1, false) + + ui.loadingScreen.SetBackgroundColor(ui.colors.background) +} + +func (ui *UI) fetchStationsAndInitUI() error { + // Show real progress stages instead of fake loading animation + ui.app.QueueUpdateDraw(func() { + ui.loadingText.SetText("Connecting to SomaFM...") + ui.progressBar.SetText("[::b]Loading: 10%[-:-:-]") + }) + + stations, err := ui.stationService.GetStations() + if err != nil { + return fmt.Errorf("failed to fetch stations: %w", err) + } + log.Debug().Msgf("Loaded %d stations", len(stations)) + + ui.app.QueueUpdateDraw(func() { + ui.loadingText.SetText("Loading configuration...") + ui.progressBar.SetText("[::b]Loading: 50%[-:-:-]") + }) + + ui.config.CleanupFavorites(ui.stationService.GetValidStationIDs()) + ui.SaveConfig() + + ui.app.QueueUpdateDraw(func() { + ui.loadingText.SetText("Building interface...") + ui.progressBar.SetText("[::b]Loading: 80%[-:-:-]") + }) + + ui.setupUI() + + ui.stationService.StartPeriodicRefresh(30*time.Second, ui.onStationsRefreshed) + + ui.app.QueueUpdateDraw(func() { + ui.progressBar.SetText("[::b]Loading: 100%[-:-:-]") + ui.pages.SwitchToPage("main") + ui.app.SetFocus(ui.stationList) + }) + + return nil +} + +func (ui *UI) setupUI() { + header := ui.createHeader() + + ui.playerPanel = tview.NewFlex().SetDirection(tview.FlexRow) + ui.playerPanel.SetBackgroundColor(ui.colors.background) + + ui.stationList = ui.createStationListTable() + + ui.helpPanel = ui.createFooter() + + ui.contentLayout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(header, HeaderHeight, 0, false). + AddItem(nil, 1, 0, false). + AddItem(ui.playerPanel, PlayerPanelHeight, 0, false). + AddItem(nil, 1, 0, false). + AddItem(ui.stationList, 0, 1, true). + AddItem(ui.helpPanel, FooterHeightWide, 0, false) + ui.contentLayout.SetBackgroundColor(ui.colors.background) + + wrapper := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(nil, 3, 0, false). + AddItem(ui.contentLayout, 0, 1, true). + AddItem(nil, 3, 0, false) + wrapper.SetBackgroundColor(ui.colors.background) + + ui.mainLayout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 1, 0, false). + AddItem(wrapper, 0, 1, true). + AddItem(nil, 1, 0, false) + ui.mainLayout.SetBackgroundColor(ui.colors.background) + + ui.pages = tview.NewPages(). + AddPage("main", ui.mainLayout, true, true) + ui.pages.SetBackgroundColor(ui.colors.background) + + ui.app.SetRoot(ui.pages, true). + EnableMouse(true) + + ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if ui.pages.HasPage("modal") { + return event + } + return ui.globalInputHandler(event) + }) + + ui.app.Draw() + + ui.app.QueueUpdateDraw(func() { + if ui.startRandom { + ui.randomStation() + } else if ui.config.LastStation != "" { + if !ui.selectAndShowStationByID(ui.config.LastStation) { + ui.selectAndShowStation(0) + } + } else { + ui.selectAndShowStation(0) + } + }) +} + +func (ui *UI) createHeader() tview.Primitive { + titleView := tview.NewTextView() + titleView.SetText(" " + config.AppName) + titleView.SetTextAlign(tview.AlignLeft) + titleView.SetTextColor(ui.colors.foreground) + titleView.SetBackgroundColor(ui.colors.headerBackground) + + versionView := tview.NewTextView() + versionView.SetText("v" + config.AppVersion + " ") + versionView.SetTextAlign(tview.AlignRight) + versionView.SetTextColor(ui.colors.foreground) + versionView.SetBackgroundColor(ui.colors.headerBackground) + + textFlex := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(titleView, 0, 1, false). + AddItem(versionView, 10, 0, false) + textFlex.SetBackgroundColor(ui.colors.headerBackground) + + topSpacer := tview.NewBox().SetBackgroundColor(ui.colors.headerBackground) + bottomSpacer := tview.NewBox().SetBackgroundColor(ui.colors.headerBackground) + leftSpacer := tview.NewBox().SetBackgroundColor(ui.colors.headerBackground) + rightSpacer := tview.NewBox().SetBackgroundColor(ui.colors.headerBackground) + + textWithPadding := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(leftSpacer, 1, 0, false). + AddItem(textFlex, 0, 1, false). + AddItem(rightSpacer, 1, 0, false) + textWithPadding.SetBackgroundColor(ui.colors.headerBackground) + + headerFlex := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(topSpacer, 1, 0, false). + AddItem(textWithPadding, 1, 0, false). + AddItem(bottomSpacer, 1, 0, false) + headerFlex.SetBackgroundColor(ui.colors.headerBackground) + + return headerFlex +} + +func (ui *UI) updateLogoPanel(s *station.Station) { + go func() { + img, err := ui.stationService.LoadImage(s.XLImage) + if err != nil { + ui.app.QueueUpdateDraw(func() { + ui.logoPanel.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { + errorMsg := fmt.Sprintf("Failed to load image: %v", err) + tview.Print(screen, errorMsg, x, y, 10, tview.AlignCenter, tcell.ColorRed) + return x, y, 10, 10 + }) + }) + return + } + + ui.app.QueueUpdateDraw(func() { + ui.logoPanel.SetImage(img) + }) + }() +} + +func (ui *UI) onStationSelected(index int) { + stationCount := ui.stationService.StationCount() + if index < 0 || index >= stationCount { + return + } + + if index == ui.playingIndex && ui.player.IsPlaying() { + return + } + + ui.player.Stop() + ui.safeCloseChannel() + ui.recreateStopChannel() + + previousPlayingIndex := ui.playingIndex + + ui.playingIndex = index + ui.currentStation = ui.stationService.GetStation(index) + ui.playingStationID = ui.currentStation.ID + + if previousPlayingIndex >= 0 && previousPlayingIndex < stationCount && previousPlayingIndex != index { + ui.setStationRow(ui.stationList, previousPlayingIndex+1, previousPlayingIndex) + } + + ui.updateStationListPlayingIndicator() + + ui.SaveConfig() + + ui.playerPanel.Clear() + + contentPanel := ui.createContentPanel() + ui.playerPanel.AddItem(contentPanel, 0, 1, false) + + ui.updateLogoPanel(ui.currentStation) + + go func() { + stationID := ui.currentStation.ID + track, err := ui.stationService.GetCurrentTrackForStation(stationID) + if err != nil { + log.Debug().Err(err).Msg("Failed to fetch song history, using lastPlaying") + return + } + if track != "" { + ui.app.QueueUpdateDraw(func() { + if ui.currentTrackView != nil { + ui.currentTrackView.SetText(fmt.Sprintf(" [%s]%s[-]", + ui.colors.highlight.String(), + track)) + } + }) + ui.player.SetInitialTrack(track) + } + }() + + ui.startPlayingAnimation() + + go func() { + log.Info().Msgf("Starting playback for station: %s", ui.currentStation.Title) + err := ui.player.Play(ui.currentStation) + if err != nil { + if errors.Is(err, context.Canceled) { + log.Debug().Msg("Playback stopped (station changed)") + return + } + log.Error().Err(err).Msg("Failed to play station") + ui.app.QueueUpdateDraw(func() { + ui.showError(fmt.Sprintf("Failed to play station: %v", err)) + }) + } + }() +} + +func (ui *UI) createGenreTags(genre string) *tview.Flex { + container := tview.NewFlex().SetDirection(tview.FlexColumn) + container.SetBackgroundColor(ui.colors.background) + + container.AddItem(tview.NewBox().SetBackgroundColor(ui.colors.background), 1, 0, false) + + if genre == "" { + noGenre := tview.NewTextView() + noGenre.SetText("N/A") + noGenre.SetTextColor(ui.colors.foreground) + noGenre.SetBackgroundColor(ui.colors.background) + container.AddItem(noGenre, 3, 0, false) + return container + } + + genres := strings.Split(genre, "|") + for i, g := range genres { + g = strings.TrimSpace(g) + + tag := tview.NewTextView() + tag.SetText(" " + g + " ") + tag.SetTextColor(ui.colors.foreground) + tag.SetBackgroundColor(ui.colors.genreTagBackground) + tag.SetTextAlign(tview.AlignCenter) + + tagWidth := len(g) + 2 + container.AddItem(tag, tagWidth, 0, false) + + if i < len(genres)-1 { + spacer := tview.NewBox().SetBackgroundColor(ui.colors.background) + container.AddItem(spacer, 1, 0, false) + } + } + + container.AddItem(tview.NewBox().SetBackgroundColor(ui.colors.background), 0, 1, false) + + return container +} + +func (ui *UI) createContentPanel() *tview.Flex { + ui.logoPanel = tview.NewImage() + ui.logoPanel.SetBackgroundColor(ui.colors.background) + ui.logoPanel.SetAlign(tview.AlignLeft, tview.AlignTop) + + stationLabel := tview.NewTextView() + stationLabel.SetText(" Station:") + stationLabel.SetTextColor(ui.colors.foreground) + stationLabel.SetBackgroundColor(ui.colors.background) + stationLabel.SetWrap(false) + + stationNameView := tview.NewTextView() + stationNameView.SetDynamicColors(true) + stationNameView.SetText(fmt.Sprintf(" [%s]%s[-]", + ui.colors.highlight.String(), + ui.currentStation.Title)) + stationNameView.SetTextColor(ui.colors.highlight) + stationNameView.SetBackgroundColor(ui.colors.background) + stationNameView.SetWrap(false) + stationNameView.SetTextStyle(tcell.StyleDefault.Background(ui.colors.background).Attributes(tcell.AttrBold)) + + playingLabel := tview.NewTextView() + playingLabel.SetText(" Playing:") + playingLabel.SetTextColor(ui.colors.foreground) + playingLabel.SetBackgroundColor(ui.colors.background) + playingLabel.SetWrap(false) + + ui.currentTrackView = tview.NewTextView() + ui.currentTrackView.SetDynamicColors(true) + ui.currentTrackView.SetText(fmt.Sprintf(" [%s]%s[-]", + ui.colors.highlight.String(), + ui.currentStation.LastPlaying)) + ui.currentTrackView.SetTextColor(ui.colors.highlight) + ui.currentTrackView.SetBackgroundColor(ui.colors.background) + ui.currentTrackView.SetWrap(true) + ui.currentTrackView.SetTextStyle(tcell.StyleDefault.Background(ui.colors.background).Attributes(tcell.AttrBold)) + + genreLabel := tview.NewTextView() + genreLabel.SetText(" Genre:") + genreLabel.SetTextColor(ui.colors.foreground) + genreLabel.SetBackgroundColor(ui.colors.background) + genreLabel.SetWrap(false) + + genreView := ui.createGenreTags(ui.currentStation.Genre) + + descriptionLabel := tview.NewTextView() + descriptionLabel.SetText(" Description:") + descriptionLabel.SetTextColor(ui.colors.foreground) + descriptionLabel.SetBackgroundColor(ui.colors.background) + descriptionLabel.SetWrap(false) + + descriptionView := tview.NewTextView() + descriptionView.SetDynamicColors(true) + descriptionView.SetText(fmt.Sprintf(" [%s]%s[-]", + ui.colors.foreground.String(), + ui.currentStation.Description)) + descriptionView.SetTextColor(ui.colors.foreground) + descriptionView.SetBackgroundColor(ui.colors.background) + descriptionView.SetWrap(true) + + infoSpacer := tview.NewBox().SetBackgroundColor(ui.colors.background) + + infoContent := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(stationLabel, 1, 0, false). + AddItem(stationNameView, 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(playingLabel, 1, 0, false). + AddItem(ui.currentTrackView, 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(genreLabel, 1, 0, false). + AddItem(genreView, 1, 0, false). + AddItem(nil, 1, 0, false). + AddItem(descriptionLabel, 1, 0, false). + AddItem(descriptionView, 0, 1, false). + AddItem(infoSpacer, 0, 1, false) + infoContent.SetBackgroundColor(ui.colors.background) + + ui.volumeView = ui.createGraphicalVolumeBar() + + // Wrap logo in vertical flex to constrain height + logoWrapper := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.logoPanel, CoverHeight, 0, false). + AddItem(nil, 0, 1, false) + logoWrapper.SetBackgroundColor(ui.colors.background) + + contentFlex := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(logoWrapper, CoverWidth, 0, false). + AddItem(infoContent, 0, 1, false). + AddItem(ui.volumeView, 7, 0, false) + contentFlex.SetBackgroundColor(ui.colors.background) + + contentWithPadding := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(nil, 4, 0, false). + AddItem(contentFlex, 0, 1, false). + AddItem(nil, 4, 0, false) + contentWithPadding.SetBackgroundColor(ui.colors.background) + + return contentWithPadding +} + +type PlayingSpinner struct { + Frames []string + FPS time.Duration +} + +func NewPlayingSpinner() *PlayingSpinner { + return &PlayingSpinner{ + Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, + FPS: time.Second / 10, + } +} + +func (ui *UI) getPlayingIndicator() string { + if ui.playingSpinner == nil { + ui.playingSpinner = NewPlayingSpinner() + } + + frameIndex := ui.animationFrame % len(ui.playingSpinner.Frames) + return ui.playingSpinner.Frames[frameIndex] +} + +func (ui *UI) startPlayingAnimation() { + if ui.playingSpinner == nil { + ui.playingSpinner = NewPlayingSpinner() + } + + go func() { + animationTicker := time.NewTicker(ui.playingSpinner.FPS) + trackUpdateTicker := time.NewTicker(5 * time.Second) + defer animationTicker.Stop() + defer trackUpdateTicker.Stop() + + for { + select { + case <-ui.stopUpdates: + return + case <-animationTicker.C: + ui.mu.Lock() + ui.animationFrame++ + ui.mu.Unlock() + + ui.statusRenderer.AdvanceAnimation() + + ui.app.QueueUpdateDraw(func() { + ui.updateStationListPlayingIndicator() + }) + case <-trackUpdateTicker.C: + ui.app.QueueUpdateDraw(func() { + ui.updateTrackInfo() + }) + } + } + }() +} + +func (ui *UI) updateTrackInfo() { + if ui.currentTrackView == nil || !ui.player.IsPlaying() { + return + } + + trackInfo := ui.player.GetCurrentTrack() + ui.currentTrackView.SetText(fmt.Sprintf(" [%s]%s[-]", + ui.colors.highlight.String(), + trackInfo)) +} + +func (ui *UI) onStationsRefreshed(stations []station.Station) { + ui.app.QueueUpdateDraw(func() { + ui.refreshStationTable() + }) +} + +func (ui *UI) globalInputHandler(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'q', 'Q': + ui.stop() + return nil + case ' ': + if ui.player.IsPlaying() || ui.player.IsPaused() { + ui.player.TogglePause() + ui.updateStationListPlayingIndicator() + } else { + row, _ := ui.stationList.GetSelection() + if row > 0 && row <= ui.stationService.StationCount() { + ui.onStationSelected(row - 1) + } + } + return nil + case '>': + ui.nextStation() + return nil + case '<': + ui.prevStation() + return nil + case 'r', 'R': + ui.randomStation() + return nil + case 'f', 'F': + ui.toggleFavorite() + return nil + case '+', '=': + ui.adjustVolume(VolumeStep) + return nil + case '-', '_': + ui.adjustVolume(-VolumeStep) + return nil + case 'm', 'M': + ui.toggleMute() + return nil + case '?': + ui.showHelpModal() + return nil + case 'a', 'A': + ui.showAboutModal() + return nil + } + case tcell.KeyEnter: + row, _ := ui.stationList.GetSelection() + if row > 0 && row <= ui.stationService.StationCount() { + ui.onStationSelected(row - 1) + } + return nil + case tcell.KeyEscape: + ui.stop() + return nil + case tcell.KeyRight: + // Right arrow - volume up (hidden shortcut) + ui.adjustVolume(VolumeStep) + return nil + case tcell.KeyLeft: + // Left arrow - volume down (hidden shortcut) + ui.adjustVolume(-VolumeStep) + return nil + } + return event +} diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go new file mode 100644 index 0000000..c38c191 --- /dev/null +++ b/internal/ui/ui_test.go @@ -0,0 +1,338 @@ +package ui + +import ( + "errors" + "testing" +) + +func TestNewPlayingSpinner(t *testing.T) { + spinner := NewPlayingSpinner() + + if spinner == nil { + t.Fatal("NewPlayingSpinner() returned nil") + } + + if len(spinner.Frames) == 0 { + t.Error("PlayingSpinner.Frames is empty") + } + + if spinner.FPS <= 0 { + t.Error("PlayingSpinner.FPS should be positive") + } +} + +func TestPlayingSpinnerFrames(t *testing.T) { + spinner := NewPlayingSpinner() + + for i, frame := range spinner.Frames { + if frame == "" { + t.Errorf("Frame[%d] is empty", i) + } + } + + if len(spinner.Frames) < 2 { + t.Errorf("Expected at least 2 frames, got %d", len(spinner.Frames)) + } +} + +func TestQualityShort(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"highest", "HQ"}, + {"high", "HQ"}, + {"medium", "MQ"}, + {"low", "LQ"}, + {"", ""}, + {"unknown", ""}, + {"HIGHEST", ""}, + {"very high", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := qualityShort(tt.input) + if result != tt.expected { + t.Errorf("qualityShort(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestJoinParts(t *testing.T) { + tests := []struct { + name string + parts []string + expected string + }{ + { + name: "empty slice", + parts: []string{}, + expected: "", + }, + { + name: "single part", + parts: []string{"PLAYING"}, + expected: "PLAYING", + }, + { + name: "two parts", + parts: []string{"PLAYING", "MP3"}, + expected: "PLAYING │ MP3", + }, + { + name: "three parts", + parts: []string{"● LIVE", "MP3 HQ", "44.1kHz"}, + expected: "● LIVE │ MP3 HQ │ 44.1kHz", + }, + { + name: "nil slice", + parts: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := joinParts(tt.parts) + if result != tt.expected { + t.Errorf("joinParts(%v) = %q, want %q", tt.parts, result, tt.expected) + } + }) + } +} + +func TestExtractErrorReason(t *testing.T) { + tests := []struct { + name string + err error + contains string + }{ + { + name: "no such host", + err: errors.New("dial tcp: lookup example.com: no such host"), + contains: "Unable to connect", + }, + { + name: "connection refused", + err: errors.New("dial tcp 127.0.0.1:80: connection refused"), + contains: "Connection refused", + }, + { + name: "timeout", + err: errors.New("context deadline exceeded (Client.Timeout exceeded)"), + contains: "timed out", + }, + { + name: "network unreachable", + err: errors.New("dial tcp: network is unreachable"), + contains: "Network is unreachable", + }, + { + name: "401 unauthorized", + err: errors.New("unexpected status 401"), + contains: "401", + }, + { + name: "403 forbidden", + err: errors.New("unexpected status 403"), + contains: "403", + }, + { + name: "404 not found", + err: errors.New("unexpected status 404"), + contains: "404", + }, + { + name: "generic error (short)", + err: errors.New("some error"), + contains: "some error", + }, + { + name: "dial error truncation", + err: errors.New("failed to connect: dial tcp something something"), + contains: "failed to connect", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractErrorReason(tt.err) + if result == "" { + t.Error("extractErrorReason returned empty string") + } + if !containsString(result, tt.contains) { + t.Errorf("extractErrorReason(%v) = %q, expected to contain %q", + tt.err, result, tt.contains) + } + }) + } +} + +func TestExtractErrorReasonLongError(t *testing.T) { + longErr := errors.New(string(make([]byte, 200))) + result := extractErrorReason(longErr) + + if len(result) > 110 { + t.Errorf("Long error not truncated properly, got length %d", len(result)) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestStatusRendererFormatBufferHealth(t *testing.T) { + renderer := &StatusRenderer{} + + tests := []struct { + percent int + expected int // expected number of characters (5 bars) + }{ + {0, 5}, + {50, 5}, + {100, 5}, + {120, 5}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + result := renderer.formatBufferHealth(tt.percent) + runeCount := 0 + for range result { + runeCount++ + } + if runeCount != tt.expected { + t.Errorf("formatBufferHealth(%d) returned %d runes, want %d", + tt.percent, runeCount, tt.expected) + } + }) + } +} + +func TestStatusRendererFormatBufferHealthProgression(t *testing.T) { + renderer := &StatusRenderer{} + + result0 := renderer.formatBufferHealth(0) + result50 := renderer.formatBufferHealth(50) + result100 := renderer.formatBufferHealth(100) + + if result0 == result100 { + t.Error("0% and 100% buffer health should look different") + } + + if result50 == result0 || result50 == result100 { + t.Log("50% buffer health representation may equal 0% or 100% due to rounding") + } +} + +func TestStatusRendererSetters(t *testing.T) { + renderer := NewStatusRenderer(nil) + + renderer.SetMuted(true) + if !renderer.isMuted { + t.Error("SetMuted(true) did not set isMuted") + } + + renderer.SetMuted(false) + if renderer.isMuted { + t.Error("SetMuted(false) did not clear isMuted") + } + + renderer.SetPrimaryColor("red") + if renderer.primaryColor != "red" { + t.Errorf("SetPrimaryColor set %q, want %q", renderer.primaryColor, "red") + } +} + +func TestStatusRendererAdvanceAnimation(t *testing.T) { + renderer := NewStatusRenderer(nil) + + initialFrame := renderer.animFrame + + for i := 0; i < renderer.ticksPerFrame-1; i++ { + renderer.AdvanceAnimation() + } + + if renderer.animFrame != initialFrame { + t.Error("Animation frame changed before ticksPerFrame ticks") + } + + renderer.AdvanceAnimation() + + if renderer.animFrame != (initialFrame+1)%renderer.maxAnimFrame { + t.Errorf("Animation frame = %d, want %d", + renderer.animFrame, (initialFrame+1)%renderer.maxAnimFrame) + } + + if renderer.tickCount != 0 { + t.Errorf("tickCount = %d, want 0 after frame advance", renderer.tickCount) + } +} + +func TestStatusRendererRenderIdle(t *testing.T) { + renderer := NewStatusRenderer(nil) + + result := renderer.renderIdle() + if result == "" { + t.Error("renderIdle() returned empty string") + } + if !findSubstring(result, "IDLE") { + t.Errorf("renderIdle() = %q, expected to contain 'IDLE'", result) + } + + renderer.SetMuted(true) + result = renderer.renderIdle() + if !findSubstring(result, "MUTED") { + t.Errorf("renderIdle() when muted = %q, expected to contain 'MUTED'", result) + } +} + +func TestStatusRendererRenderBuffering(t *testing.T) { + renderer := NewStatusRenderer(nil) + + result := renderer.renderBuffering() + if result == "" { + t.Error("renderBuffering() returned empty string") + } + if !findSubstring(result, "BUFFERING") { + t.Errorf("renderBuffering() = %q, expected to contain 'BUFFERING'", result) + } +} + +// Note: renderReconnecting, renderPlaying, renderPaused, and renderError +// require a non-nil player to get state info. Testing those would require +// a mock player, which is beyond the scope of these pure function tests. +// The core formatting logic is still covered by formatBufferHealth, +// formatDuration, qualityShort, and joinParts tests. + +func TestNewStatusRenderer(t *testing.T) { + renderer := NewStatusRenderer(nil) + + if renderer == nil { + t.Fatal("NewStatusRenderer() returned nil") + } + + if renderer.maxAnimFrame <= 0 { + t.Error("maxAnimFrame should be positive") + } + + if renderer.ticksPerFrame <= 0 { + t.Error("ticksPerFrame should be positive") + } + + if renderer.bufferTicksPerUpdate <= 0 { + t.Error("bufferTicksPerUpdate should be positive") + } +} diff --git a/internal/ui/volume.go b/internal/ui/volume.go new file mode 100644 index 0000000..bd89200 --- /dev/null +++ b/internal/ui/volume.go @@ -0,0 +1,149 @@ +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/glebovdev/somafm-cli/internal/config" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" +) + +func (ui *UI) buildVolumeBar(container *tview.Flex) { + const barHeight = 10 + + ui.mu.Lock() + displayVolume := ui.currentVolume + isMuted := ui.isMuted + if isMuted { + displayVolume = ui.config.Volume + } + ui.mu.Unlock() + + filledLines := (displayVolume * barHeight) / 100 + emptyLines := barHeight - filledLines + + createText := func(text string, color tcell.Color) *tview.TextView { + tv := tview.NewTextView() + tv.SetText(text) + tv.SetTextAlign(tview.AlignRight) + tv.SetTextColor(color) + tv.SetBackgroundColor(ui.colors.background) + return tv + } + + createBarLine := func(barText string, barColor tcell.Color, showPercent bool) *tview.Flex { + line := tview.NewFlex().SetDirection(tview.FlexColumn) + line.SetBackgroundColor(ui.colors.background) + + if showPercent { + percentText := fmt.Sprintf("%d%%", displayVolume) + + var percentColor tcell.Color + if isMuted { + percentColor = config.GetColor(ui.config.Theme.MutedVolume) + } else { + percentColor = ui.colors.highlight + } + + percentView := createText(percentText, percentColor) + percentView.SetTextAlign(tview.AlignRight) + + if isMuted { + percentView.SetTextStyle(tcell.StyleDefault. + Foreground(percentColor). + Background(ui.colors.background). + Attributes(tcell.AttrStrikeThrough)) + } + + line.AddItem(percentView, 4, 0, false) + } else { + line.AddItem(createText(" ", ui.colors.foreground), 4, 0, false) + } + + line.AddItem(createText(barText, barColor), 0, 1, false) + + return line + } + + container.AddItem(createText(" max", ui.colors.foreground), 1, 0, false) + + for i := 0; i < emptyLines; i++ { + container.AddItem(createBarLine(" ░░", ui.colors.foreground, false), 1, 0, false) + } + + barColor := ui.colors.highlight + if isMuted { + barColor = config.GetColor(ui.config.Theme.MutedVolume) + } + for i := 0; i < filledLines; i++ { + showPercent := (i == 0) + container.AddItem(createBarLine(" ██", barColor, showPercent), 1, 0, false) + } + + container.AddItem(createText(" min", ui.colors.foreground), 1, 0, false) + + container.AddItem(nil, 0, 1, false) +} + +func (ui *UI) createGraphicalVolumeBar() *tview.Flex { + volumeContainer := tview.NewFlex().SetDirection(tview.FlexRow) + volumeContainer.SetBackgroundColor(ui.colors.background) + ui.buildVolumeBar(volumeContainer) + return volumeContainer +} + +func (ui *UI) updateVolumeDisplay() { + if ui.volumeView != nil { + ui.volumeView.Clear() + ui.buildVolumeBar(ui.volumeView) + } +} + +func (ui *UI) adjustVolume(delta int) { + ui.mu.Lock() + + if ui.isMuted { + ui.currentVolume = ui.config.Volume + ui.isMuted = false + ui.statusRenderer.SetMuted(false) + ui.mu.Unlock() + + ui.player.SetVolume(ui.currentVolume) + ui.updateVolumeDisplay() + log.Debug().Msgf("Auto-unmuted, restored volume to %d%%", ui.currentVolume) + return + } + + ui.currentVolume = config.ClampVolume(ui.currentVolume + delta) + ui.mu.Unlock() + + ui.player.SetVolume(ui.currentVolume) + ui.updateVolumeDisplay() + ui.SaveConfig() + log.Debug().Msgf("Volume adjusted to %d%%", ui.currentVolume) +} + +func (ui *UI) toggleMute() { + ui.mu.Lock() + if ui.isMuted { + ui.currentVolume = ui.config.Volume + ui.isMuted = false + log.Debug().Msgf("Unmuted, restored volume to %d%%", ui.currentVolume) + } else { + if ui.currentVolume == 0 { + ui.config.Volume = config.DefaultVolume + } else { + ui.config.Volume = ui.currentVolume + } + ui.currentVolume = 0 + ui.isMuted = true + log.Debug().Msgf("Muted, saved volume %d%%", ui.config.Volume) + } + ui.statusRenderer.SetMuted(ui.isMuted) + ui.mu.Unlock() + + ui.player.SetVolume(ui.currentVolume) + ui.updateVolumeDisplay() + ui.SaveConfig() +}