feat: upload qrz.com clublog and lotw manually

This commit is contained in:
2026-05-29 00:16:59 +02:00
parent edda183c16
commit 33a7b6c4ac
12 changed files with 1113 additions and 41 deletions
+144 -15
View File
@@ -4,6 +4,7 @@ import (
"context"
"math/rand"
"net/http"
"strings"
"sync"
"time"
)
@@ -26,6 +27,12 @@ type Deps struct {
// NotifyError surfaces a failed upload (logging + optional UI event).
NotifyError func(svc Service, id int64, err error)
// ShouldUpload reports whether a QSO is eligible for upload to this
// service, based on its sent status: QRZ/Club Log upload anything not
// yet "Y"; LoTW uploads only QSOs whose lotw_sent matches the configured
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
ShouldUpload func(svc Service, id int64) bool
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
@@ -36,9 +43,10 @@ type Deps struct {
type Manager struct {
deps Deps
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
}
func NewManager(deps Deps) *Manager {
@@ -49,7 +57,8 @@ func NewManager(deps Deps) *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())),
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
pending: map[Service][]int64{},
}
}
@@ -91,13 +100,30 @@ func (m *Manager) OnQSOLogged(id int64) {
// QRZ.com
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
m.scheduleUpload(ServiceQRZ, id, qrz)
m.route(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)
m.route(ServiceClublog, id, cl)
}
// LoTW will be added here.
// LoTW — needs TQSL + a station location.
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
m.route(ServiceLoTW, id, lt)
}
}
// route sends a logged QSO down the configured timing path: queue it for the
// app-close batch, or schedule an immediate / delayed upload.
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeOnClose {
m.mu.Lock()
m.pending[svc] = append(m.pending[svc], id)
n := len(m.pending[svc])
m.mu.Unlock()
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
return
}
m.scheduleUpload(svc, id, cfg)
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
@@ -111,10 +137,104 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
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) {
// PendingCount returns how many QSOs are queued for on-close upload across
// all services. The shutdown sequence uses it to decide whether to show the
// upload step.
func (m *Manager) PendingCount() int {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, ids := range m.pending {
n += len(ids)
}
return n
}
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
// single TQSL batch. Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
m.mu.Lock()
pending := m.pending
m.pending = map[Service][]int64{}
cfg := m.cfg
m.mu.Unlock()
uploaded := 0
for svc, ids := range pending {
if len(ids) == 0 {
continue
}
switch svc {
case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
default:
var sc ServiceConfig
switch svc {
case ServiceQRZ:
sc = cfg.QRZ
case ServiceClublog:
sc = cfg.Clublog
}
for _, id := range ids {
if m.upload(svc, id, sc) {
uploaded++
}
}
}
}
return uploaded
}
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
// stamps each as uploaded on success.
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
var records []string
var kept []int64
for _, id := range ids {
// Skip QSOs not eligible (sent status doesn't match Upload flag).
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
continue
}
if rec, ok := m.deps.BuildADIF(id, ""); ok {
records = append(records, rec)
kept = append(kept, id)
}
}
if len(records) == 0 {
return 0
}
res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n"))
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceLoTW, 0, err)
}
return 0
}
m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept))
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceLoTW, id, res.LogID)
}
}
return len(kept)
}
// upload performs the actual push and returns true on success. 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) bool {
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
// match the configured Upload flag).
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -126,7 +246,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
return false
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
@@ -135,11 +255,19 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
return false
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
case ServiceLoTW:
// LoTW signs the QSO's own station call via TQSL — no override.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
}
res, err = UploadLoTW(ctx, cfg, "", record)
default:
return
return false
}
if err != nil || !res.OK {
@@ -150,11 +278,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return
return false
}
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)
}
return true
}