161 lines
4.6 KiB
Go
161 lines
4.6 KiB
Go
package extsvc
|
||
|
||
import (
|
||
"context"
|
||
"math/rand"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||
// keeps the upload-scheduling logic testable.
|
||
type Deps struct {
|
||
Client *http.Client
|
||
|
||
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
|
||
// overridden by forceCall when non-empty. ok=false means "skip silently"
|
||
// (row gone, missing required fields, …).
|
||
BuildADIF func(id int64, forceCall string) (record string, ok bool)
|
||
|
||
// MarkUploaded stamps the per-service upload status on the QSO row and
|
||
// notifies the UI. Called once, on success.
|
||
MarkUploaded func(svc Service, id int64, logID string)
|
||
|
||
// NotifyError surfaces a failed upload (logging + optional UI event).
|
||
NotifyError func(svc Service, id int64, err error)
|
||
|
||
// Logf is an optional diagnostic logger.
|
||
Logf func(format string, args ...any)
|
||
}
|
||
|
||
// Manager owns the external-service config snapshot and schedules uploads
|
||
// when a QSO is logged. Immediate uploads run in their own goroutine;
|
||
// delayed uploads use a timer with a random 1–2 minute fuse.
|
||
type Manager struct {
|
||
deps Deps
|
||
|
||
mu sync.Mutex
|
||
cfg ExternalServices
|
||
rnd *rand.Rand
|
||
}
|
||
|
||
func NewManager(deps Deps) *Manager {
|
||
if deps.Client == nil {
|
||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||
}
|
||
return &Manager{
|
||
deps: deps,
|
||
// Seeded from the clock; the delay only needs to be unpredictable
|
||
// enough to spread bursts, not cryptographically random.
|
||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||
}
|
||
}
|
||
|
||
func (m *Manager) logf(format string, args ...any) {
|
||
if m.deps.Logf != nil {
|
||
m.deps.Logf(format, args...)
|
||
}
|
||
}
|
||
|
||
// SetConfig replaces the active config snapshot (called after the user
|
||
// saves the External Services settings).
|
||
func (m *Manager) SetConfig(cfg ExternalServices) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
cfg.QRZ = cfg.QRZ.normalised()
|
||
m.cfg = cfg
|
||
}
|
||
|
||
// Config returns the current snapshot.
|
||
func (m *Manager) Config() ExternalServices {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.cfg
|
||
}
|
||
|
||
// delaySeconds returns a random 60–120s fuse for delayed uploads.
|
||
func (m *Manager) delaySeconds() time.Duration {
|
||
m.mu.Lock()
|
||
d := 60 + m.rnd.Intn(61) // [60, 120]
|
||
m.mu.Unlock()
|
||
return time.Duration(d) * time.Second
|
||
}
|
||
|
||
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
|
||
// auto-log). It fans out to every enabled, auto-upload service in the
|
||
// configured timing mode. Returns immediately.
|
||
func (m *Manager) OnQSOLogged(id int64) {
|
||
cfg := m.Config()
|
||
|
||
// QRZ.com
|
||
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
||
m.scheduleUpload(ServiceQRZ, id, qrz)
|
||
}
|
||
// Club Log — email + password + callsign are enough (no API key).
|
||
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
||
m.scheduleUpload(ServiceClublog, id, cl)
|
||
}
|
||
// LoTW will be added here.
|
||
}
|
||
|
||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||
if cfg.UploadMode == ModeDelayed {
|
||
d := m.delaySeconds()
|
||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
||
return
|
||
}
|
||
go m.upload(svc, id, cfg)
|
||
}
|
||
|
||
// upload performs the actual push. It builds a fresh, lifecycle-independent
|
||
// context so a delayed upload still completes even if it fires close to
|
||
// shutdown.
|
||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
var res UploadResult
|
||
var err error
|
||
switch svc {
|
||
case ServiceQRZ:
|
||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||
if !ok {
|
||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||
return
|
||
}
|
||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||
case ServiceClublog:
|
||
// Club Log takes the logbook callsign as a separate param, so the
|
||
// ADIF keeps the QSO's own station call (no override).
|
||
record, ok := m.deps.BuildADIF(id, "")
|
||
if !ok {
|
||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||
return
|
||
}
|
||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||
default:
|
||
return
|
||
}
|
||
|
||
if err != nil || !res.OK {
|
||
if err == nil {
|
||
err = errFromResult(res)
|
||
}
|
||
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
|
||
if m.deps.NotifyError != nil {
|
||
m.deps.NotifyError(svc, id, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||
if m.deps.MarkUploaded != nil {
|
||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||
}
|
||
}
|