mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat: implement POST /key endpoint for media controls with host:port parsing
Major Features: • POST /key endpoint implementation with XML model and validation • Comprehensive media control commands (play, pause, stop, volume, presets) • Automatic host:port parsing in CLI for improved UX • Production-ready with full test coverage Key Control Implementation: • Add Key model with XML marshaling and validation (pkg/models/key.go) • Support all standard keys: PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, VOLUME_UP/DOWN, PRESET_1-6 • Client methods: SendKey(), Play(), Pause(), Stop(), VolumeUp(), VolumeDown(), SelectPreset() • CLI commands: -play, -pause, -stop, -next, -prev, -volume-up, -volume-down, -preset, -key • Critical fix: Use 'Gabbo' as sender (only accepted value by SoundTouch API) Host:Port Parsing Enhancement: • Support -host 192.168.178.28:8090 format in addition to separate -host/-port flags • Robust parsing with IPv4, IPv6, and hostname support • Graceful fallback for invalid input • Backward compatible with existing usage Testing & Documentation: • Comprehensive unit tests for key functionality and host:port parsing • Integration tested with real SoundTouch 10 and SoundTouch 20 devices • Complete documentation in docs/KEY-CONTROLS.md and docs/HOST-PORT-PARSING.md • All tests pass, no diagnostics errors Breaking Changes: None Backward Compatibility: Fully maintained Tested with: • SoundTouch 10 (192.168.178.28:8090) ✅ • SoundTouch 20 (192.168.178.35:8090) ✅
This commit is contained in:
+177
-9
@@ -5,6 +5,8 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +16,33 @@ import (
|
||||
"github.com/user_account/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// parseHostPort splits a host:port string into separate host and port components
|
||||
// If no port is specified, returns the original host and the provided default port
|
||||
func parseHostPort(hostPort string, defaultPort int) (string, int) {
|
||||
// Check if host contains a port (has a colon)
|
||||
if strings.Contains(hostPort, ":") {
|
||||
host, portStr, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
// If parsing fails, return original host and default port
|
||||
return hostPort, defaultPort
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
// If port parsing fails or is invalid, return host and default port
|
||||
return host, defaultPort
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
// No port specified, return original host and default port
|
||||
return hostPort, defaultPort
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address")
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
|
||||
port = flag.Int("port", 8090, "SoundTouch device port")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
|
||||
discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
|
||||
@@ -27,6 +53,15 @@ func main() {
|
||||
name = flag.Bool("name", false, "Get device name")
|
||||
capabilities = flag.Bool("capabilities", false, "Get device capabilities")
|
||||
presets = flag.Bool("presets", false, "Get configured presets")
|
||||
key = flag.String("key", "", "Send key command (PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, VOLUME_UP, VOLUME_DOWN, PRESET_1-6)")
|
||||
play = flag.Bool("play", false, "Send PLAY key command")
|
||||
pause = flag.Bool("pause", false, "Send PAUSE key command")
|
||||
stop = flag.Bool("stop", false, "Send STOP key command")
|
||||
next = flag.Bool("next", false, "Send NEXT_TRACK key command")
|
||||
prev = flag.Bool("prev", false, "Send PREV_TRACK key command")
|
||||
volumeUp = flag.Bool("volume-up", false, "Send VOLUME_UP key command")
|
||||
volumeDown = flag.Bool("volume-down", false, "Send VOLUME_DOWN key command")
|
||||
preset = flag.Int("preset", 0, "Select preset (1-6)")
|
||||
help = flag.Bool("help", false, "Show help")
|
||||
)
|
||||
|
||||
@@ -38,11 +73,18 @@ func main() {
|
||||
}
|
||||
|
||||
// If no specific action is requested, show help
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *host == "" {
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && *preset == 0 && *host == "" {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse host:port if provided
|
||||
var finalHost string
|
||||
var finalPort int
|
||||
if *host != "" {
|
||||
finalHost, finalPort = parseHostPort(*host, *port)
|
||||
}
|
||||
|
||||
// Handle discovery
|
||||
if *discover || *discoverAll {
|
||||
if err := handleDiscovery(*discoverAll, *timeout); err != nil {
|
||||
@@ -56,7 +98,7 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for info command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleDeviceInfo(*host, *port, *timeout); err != nil {
|
||||
if err := handleDeviceInfo(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get device info: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -67,7 +109,7 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for nowplaying command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleNowPlaying(*host, *port, *timeout); err != nil {
|
||||
if err := handleNowPlaying(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get now playing: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -78,7 +120,7 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for sources command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleSources(*host, *port, *timeout); err != nil {
|
||||
if err := handleSources(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get sources: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -89,7 +131,7 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for name command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleName(*host, *port, *timeout); err != nil {
|
||||
if err := handleName(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get device name: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -100,7 +142,7 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for capabilities command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleCapabilities(*host, *port, *timeout); err != nil {
|
||||
if err := handleCapabilities(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get device capabilities: %v", err)
|
||||
}
|
||||
return
|
||||
@@ -111,11 +153,22 @@ func main() {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for presets command. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handlePresets(*host, *port, *timeout); err != nil {
|
||||
if err := handlePresets(finalHost, finalPort, *timeout); err != nil {
|
||||
log.Fatalf("Failed to get presets: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle key commands
|
||||
if *key != "" || *play || *pause || *stop || *next || *prev || *volumeUp || *volumeDown || *preset > 0 {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for key commands. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleKeyCommands(finalHost, finalPort, *timeout, *key, *play, *pause, *stop, *next, *prev, *volumeUp, *volumeDown, *preset); err != nil {
|
||||
log.Fatalf("Failed to send key command: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
@@ -125,7 +178,7 @@ func printHelp() {
|
||||
fmt.Println(" soundtouch-cli [options]")
|
||||
fmt.Println()
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" -host <ip> SoundTouch device IP address")
|
||||
fmt.Println(" -host <ip> SoundTouch device IP address (or host:port)")
|
||||
fmt.Println(" -port <port> SoundTouch device port (default: 8090)")
|
||||
fmt.Println(" -timeout <dur> Request timeout (default: 10s)")
|
||||
fmt.Println(" -discover Discover SoundTouch devices via UPnP")
|
||||
@@ -136,17 +189,32 @@ func printHelp() {
|
||||
fmt.Println(" -name Get device name (requires -host)")
|
||||
fmt.Println(" -capabilities Get device capabilities (requires -host)")
|
||||
fmt.Println(" -presets Get configured presets (requires -host)")
|
||||
fmt.Println(" -key <key> Send key command (requires -host)")
|
||||
fmt.Println(" -play Send PLAY key command (requires -host)")
|
||||
fmt.Println(" -pause Send PAUSE key command (requires -host)")
|
||||
fmt.Println(" -stop Send STOP key command (requires -host)")
|
||||
fmt.Println(" -next Send NEXT_TRACK key command (requires -host)")
|
||||
fmt.Println(" -prev Send PREV_TRACK key command (requires -host)")
|
||||
fmt.Println(" -volume-up Send VOLUME_UP key command (requires -host)")
|
||||
fmt.Println(" -volume-down Send VOLUME_DOWN key command (requires -host)")
|
||||
fmt.Println(" -preset <1-6> Select preset (requires -host)")
|
||||
fmt.Println(" -help Show this help message")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" soundtouch-cli -discover")
|
||||
fmt.Println(" soundtouch-cli -discover-all")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -info")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -info")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -nowplaying")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -sources")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -name")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -capabilities")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -presets")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -play")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -pause")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -volume-up")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100:8090 -preset 1")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -key STOP")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info")
|
||||
}
|
||||
|
||||
@@ -724,3 +792,103 @@ func handlePresets(host string, port int, timeout time.Duration) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleKeyCommands(host string, port int, timeout time.Duration, key string, play, pause, stop, next, prev, volumeUp, volumeDown bool, preset int) error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Override config with command line arguments if provided
|
||||
if timeout > 0 {
|
||||
cfg.HTTPTimeout = timeout
|
||||
}
|
||||
|
||||
clientConfig := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}
|
||||
|
||||
soundtouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Count how many commands are requested
|
||||
commandCount := 0
|
||||
var commandName string
|
||||
|
||||
if key != "" {
|
||||
commandCount++
|
||||
commandName = fmt.Sprintf("key %s", key)
|
||||
}
|
||||
if play {
|
||||
commandCount++
|
||||
commandName = "PLAY"
|
||||
}
|
||||
if pause {
|
||||
commandCount++
|
||||
commandName = "PAUSE"
|
||||
}
|
||||
if stop {
|
||||
commandCount++
|
||||
commandName = "STOP"
|
||||
}
|
||||
if next {
|
||||
commandCount++
|
||||
commandName = "NEXT_TRACK"
|
||||
}
|
||||
if prev {
|
||||
commandCount++
|
||||
commandName = "PREV_TRACK"
|
||||
}
|
||||
if volumeUp {
|
||||
commandCount++
|
||||
commandName = "VOLUME_UP"
|
||||
}
|
||||
if volumeDown {
|
||||
commandCount++
|
||||
commandName = "VOLUME_DOWN"
|
||||
}
|
||||
if preset > 0 {
|
||||
commandCount++
|
||||
commandName = fmt.Sprintf("PRESET_%d", preset)
|
||||
}
|
||||
|
||||
// Only allow one command at a time
|
||||
if commandCount > 1 {
|
||||
return fmt.Errorf("only one key command can be sent at a time")
|
||||
}
|
||||
if commandCount == 0 {
|
||||
return fmt.Errorf("no key command specified")
|
||||
}
|
||||
|
||||
fmt.Printf("Sending %s command to %s:%d...\n", commandName, host, port)
|
||||
|
||||
// Execute the appropriate command
|
||||
if key != "" {
|
||||
err = soundtouchClient.SendKey(strings.ToUpper(key))
|
||||
} else if play {
|
||||
err = soundtouchClient.Play()
|
||||
} else if pause {
|
||||
err = soundtouchClient.Pause()
|
||||
} else if stop {
|
||||
err = soundtouchClient.Stop()
|
||||
} else if next {
|
||||
err = soundtouchClient.NextTrack()
|
||||
} else if prev {
|
||||
err = soundtouchClient.PrevTrack()
|
||||
} else if volumeUp {
|
||||
err = soundtouchClient.VolumeUp()
|
||||
} else if volumeDown {
|
||||
err = soundtouchClient.VolumeDown()
|
||||
} else if preset > 0 {
|
||||
err = soundtouchClient.SelectPreset(preset)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send key command: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ %s command sent successfully\n", commandName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseHostPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
defaultPort int
|
||||
wantHost string
|
||||
wantPort int
|
||||
}{
|
||||
{
|
||||
name: "IPv4 with port",
|
||||
input: "192.168.1.100:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "IPv4 without port",
|
||||
input: "192.168.1.100",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "hostname with port",
|
||||
input: "soundtouch.local:9090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "soundtouch.local",
|
||||
wantPort: 9090,
|
||||
},
|
||||
{
|
||||
name: "hostname without port",
|
||||
input: "soundtouch.local",
|
||||
defaultPort: 8080,
|
||||
wantHost: "soundtouch.local",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "localhost with port",
|
||||
input: "localhost:3000",
|
||||
defaultPort: 8080,
|
||||
wantHost: "localhost",
|
||||
wantPort: 3000,
|
||||
},
|
||||
{
|
||||
name: "IPv6 with port",
|
||||
input: "[::1]:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "::1",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "IPv6 without port",
|
||||
input: "::1",
|
||||
defaultPort: 8080,
|
||||
wantHost: "::1",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - non-numeric",
|
||||
input: "192.168.1.100:abc",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - too high",
|
||||
input: "192.168.1.100:99999",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - zero",
|
||||
input: "192.168.1.100:0",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "invalid port - negative",
|
||||
input: "192.168.1.100:-1",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
defaultPort: 8080,
|
||||
wantHost: "",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "just colon",
|
||||
input: ":",
|
||||
defaultPort: 8080,
|
||||
wantHost: "",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "multiple colons - malformed",
|
||||
input: "192.168.1.100:8090:extra",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100:8090:extra",
|
||||
wantPort: 8080,
|
||||
},
|
||||
{
|
||||
name: "standard SoundTouch default",
|
||||
input: "192.168.1.100:8090",
|
||||
defaultPort: 8090,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "valid high port",
|
||||
input: "192.168.1.100:65535",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 65535,
|
||||
},
|
||||
{
|
||||
name: "valid low port",
|
||||
input: "192.168.1.100:1",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 1,
|
||||
},
|
||||
{
|
||||
name: "real SoundTouch device example",
|
||||
input: "192.168.1.35:8090",
|
||||
defaultPort: 8080,
|
||||
wantHost: "192.168.1.35",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "hostname only fallback",
|
||||
input: "bose-soundtouch-20",
|
||||
defaultPort: 8090,
|
||||
wantHost: "bose-soundtouch-20",
|
||||
wantPort: 8090,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotHost, gotPort := parseHostPort(tt.input, tt.defaultPort)
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseHostPort() host = %v, want %v", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseHostPort() port = %v, want %v", gotPort, tt.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseHostPort(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"with_port", "192.168.1.100:8090"},
|
||||
{"without_port", "192.168.1.100"},
|
||||
{"hostname_with_port", "soundtouch.local:8090"},
|
||||
{"ipv6_with_port", "[::1]:8090"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
parseHostPort(tc.input, 8080)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test edge cases with real-world SoundTouch scenarios
|
||||
func TestParseHostPortSoundTouchScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
defaultPort int
|
||||
description string
|
||||
wantHost string
|
||||
wantPort int
|
||||
}{
|
||||
{
|
||||
name: "typical_cli_usage",
|
||||
input: "192.168.1.100:8090",
|
||||
defaultPort: 8090,
|
||||
description: "User specifies full host:port",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "discovery_result_host_only",
|
||||
input: "192.168.1.100",
|
||||
defaultPort: 8090,
|
||||
description: "Discovery returns IP, CLI uses default port",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "custom_port_override",
|
||||
input: "192.168.1.100:9000",
|
||||
defaultPort: 8090,
|
||||
description: "User overrides default SoundTouch port",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 9000,
|
||||
},
|
||||
{
|
||||
name: "hostname_resolution",
|
||||
input: "bose-kitchen.local:8090",
|
||||
defaultPort: 8090,
|
||||
description: "mDNS/Bonjour hostname with port",
|
||||
wantHost: "bose-kitchen.local",
|
||||
wantPort: 8090,
|
||||
},
|
||||
{
|
||||
name: "invalid_port_fallback",
|
||||
input: "192.168.1.100:invalid",
|
||||
defaultPort: 8090,
|
||||
description: "Malformed port should fallback to default",
|
||||
wantHost: "192.168.1.100",
|
||||
wantPort: 8090,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotHost, gotPort := parseHostPort(tt.input, tt.defaultPort)
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseHostPort() host = %v, want %v (scenario: %s)", gotHost, tt.wantHost, tt.description)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseHostPort() port = %v, want %v (scenario: %s)", gotPort, tt.wantPort, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user