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 }