288 lines
5.8 KiB
Go
288 lines
5.8 KiB
Go
package cast
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/barnybug/go-cast"
|
|
"github.com/pkg/errors"
|
|
"gitlab.com/wpetit/goweb/logger"
|
|
)
|
|
|
|
type Device struct {
|
|
UUID string `goja:"uuid" json:"uuid"`
|
|
Host net.IP `goja:"host" json:"host"`
|
|
Port int `goja:"port" json:"port"`
|
|
Name string `goja:"name" json:"name"`
|
|
}
|
|
|
|
type CachedDevice struct {
|
|
Device
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
func (d CachedDevice) Expired() bool {
|
|
return d.UpdatedAt.Add(30 * time.Minute).Before(time.Now())
|
|
}
|
|
|
|
type DeviceStatus struct {
|
|
CurrentApp DeviceStatusCurrentApp `goja:"currentApp" json:"currentApp"`
|
|
Volume DeviceStatusVolume `goja:"volume" json:"volume"`
|
|
}
|
|
|
|
type DeviceStatusCurrentApp struct {
|
|
ID string `goja:"id" json:"id"`
|
|
DisplayName string `goja:"displayName" json:"displayName"`
|
|
StatusText string `goja:"statusText" json:"statusText"`
|
|
}
|
|
|
|
type DeviceStatusVolume struct {
|
|
Level float64 `goja:"level" json:"level"`
|
|
Muted bool `goja:"muted" json:"muted"`
|
|
}
|
|
|
|
const (
|
|
serviceDiscoveryPollingInterval time.Duration = 500 * time.Millisecond
|
|
)
|
|
|
|
var cache sync.Map
|
|
|
|
func getCachedDevice(uuid string) (Device, bool) {
|
|
value, exists := cache.Load(uuid)
|
|
if !exists {
|
|
return Device{}, false
|
|
}
|
|
|
|
cachedDevice, ok := value.(CachedDevice)
|
|
if !ok {
|
|
return Device{}, false
|
|
}
|
|
|
|
if cachedDevice.Expired() {
|
|
return Device{}, false
|
|
}
|
|
|
|
return cachedDevice.Device, true
|
|
}
|
|
|
|
func cacheDevice(dev Device) {
|
|
cache.Store(dev.UUID, CachedDevice{
|
|
Device: dev,
|
|
UpdatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
func getDeviceClientByUUID(ctx context.Context, uuid string) (*cast.Client, error) {
|
|
device, err := FindDeviceByUUID(ctx, uuid)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
client := cast.NewClient(device.Host, device.Port)
|
|
|
|
return client, nil
|
|
}
|
|
|
|
func FindDeviceByUUID(ctx context.Context, uuid string) (Device, error) {
|
|
device, exists := getCachedDevice(uuid)
|
|
if exists {
|
|
return device, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
devices, err := SearchDevices(ctx)
|
|
if err != nil {
|
|
return Device{}, nil
|
|
}
|
|
|
|
for dev := range devices {
|
|
if dev.UUID == uuid {
|
|
return dev, nil
|
|
}
|
|
}
|
|
|
|
return Device{}, errors.Errorf("could not find device '%s'", uuid)
|
|
}
|
|
|
|
func ListDevices(ctx context.Context, refresh bool) ([]Device, error) {
|
|
devices := make([]Device, 0)
|
|
|
|
if !refresh {
|
|
cache.Range(func(key, value any) bool {
|
|
cached, ok := value.(CachedDevice)
|
|
if !ok || cached.Expired() {
|
|
return true
|
|
}
|
|
|
|
devices = append(devices, cached.Device)
|
|
return true
|
|
})
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
ch, err := SearchDevices(ctx)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
for dev := range ch {
|
|
devices = append(devices, dev)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
var searchDevicesMutex sync.Mutex
|
|
|
|
func SearchDevices(ctx context.Context) (chan Device, error) {
|
|
service := NewService(ctx)
|
|
defer service.Stop()
|
|
|
|
go func() {
|
|
searchDevicesMutex.Lock()
|
|
defer searchDevicesMutex.Unlock()
|
|
|
|
if err := service.Run(ctx, serviceDiscoveryPollingInterval); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
|
logger.Error(ctx, "error while running cast service discovery", logger.CapturedE(errors.WithStack(err)))
|
|
}
|
|
}()
|
|
|
|
devices := make(chan Device)
|
|
|
|
go func() {
|
|
defer close(devices)
|
|
|
|
found := make(map[string]struct{})
|
|
|
|
LOOP:
|
|
for {
|
|
select {
|
|
case c := <-service.Found():
|
|
dev := Device{
|
|
Host: c.IP().To4(),
|
|
Port: c.Port(),
|
|
Name: c.Name(),
|
|
UUID: c.Uuid(),
|
|
}
|
|
|
|
if _, exists := found[dev.UUID]; !exists {
|
|
devices <- dev
|
|
|
|
found[dev.UUID] = struct{}{}
|
|
}
|
|
|
|
cacheDevice(dev)
|
|
|
|
case <-ctx.Done():
|
|
break LOOP
|
|
}
|
|
}
|
|
}()
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
var loadURLMutex sync.Mutex
|
|
|
|
func LoadURL(ctx context.Context, deviceUUID string, url string) error {
|
|
loadURLMutex.Lock()
|
|
defer loadURLMutex.Unlock()
|
|
|
|
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
if err := client.Connect(ctx); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
defer client.Close()
|
|
|
|
controller, err := client.URL(ctx)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
// Ignore context.DeadlineExceeded errors. github.com/barnybug/go-cast bug ?
|
|
if _, err := controller.LoadURL(ctx, url); err != nil && !isLoadURLContextExceeded(err) {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// False positive workaround.
|
|
func isLoadURLContextExceeded(err error) bool {
|
|
return err.Error() == "Failed to send load command: context deadline exceeded"
|
|
}
|
|
|
|
var stopCastMutex sync.Mutex
|
|
|
|
func StopCast(ctx context.Context, deviceUUID string) error {
|
|
stopCastMutex.Lock()
|
|
defer stopCastMutex.Unlock()
|
|
|
|
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
if err := client.Connect(ctx); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
defer client.Close()
|
|
|
|
if _, err := client.Receiver().QuitApp(ctx); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var getStatusMutex sync.Mutex
|
|
|
|
func getStatus(ctx context.Context, deviceUUID string) (*DeviceStatus, error) {
|
|
getStatusMutex.Lock()
|
|
defer getStatusMutex.Unlock()
|
|
|
|
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
if err := client.Connect(ctx); err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
defer client.Close()
|
|
|
|
ctrlStatus, err := client.Receiver().GetStatus(ctx)
|
|
if err != nil {
|
|
return nil, errors.WithStack(err)
|
|
}
|
|
|
|
status := &DeviceStatus{
|
|
CurrentApp: DeviceStatusCurrentApp{
|
|
ID: "",
|
|
DisplayName: "",
|
|
StatusText: "",
|
|
},
|
|
Volume: DeviceStatusVolume{
|
|
Level: *ctrlStatus.Volume.Level,
|
|
Muted: *ctrlStatus.Volume.Muted,
|
|
},
|
|
}
|
|
|
|
if len(ctrlStatus.Applications) > 0 {
|
|
status.CurrentApp.ID = *ctrlStatus.Applications[0].AppID
|
|
status.CurrentApp.DisplayName = *ctrlStatus.Applications[0].DisplayName
|
|
status.CurrentApp.StatusText = *ctrlStatus.Applications[0].StatusText
|
|
}
|
|
|
|
return status, nil
|
|
}
|