diff --git a/ANOTHER_RELATED_REPOS.md b/ANOTHER_RELATED_REPOS.md index 96b400c..82d43ee 100644 --- a/ANOTHER_RELATED_REPOS.md +++ b/ANOTHER_RELATED_REPOS.md @@ -58,7 +58,7 @@ Do not rsync lab `server.js` onto mm05 as-is. |---|---|---|---| | `/opt/golang-tests/stream-player` | Go desktop MegaPlayRadio (this repo) | `https://git.bvps.com.br/tiago/GoRadioPlayer.git` | Default `defaultWSURL` is **prod**. Lab needs `devel_device.mark` or `MMRADIO_WS_URL`. | | `/srv/pocs/wss-radio-poc-devel` | **WSS devel** (players) + **admin panel (devel only)** | lab checkout, branch `remoto` | Player WSS: `server.js` `:60010` + `desktopapp-store.js`. Admin GUI: `deskapp-server.js` `:60120` — **not shipped to mm05**. Env: **`.env.devel`**. | -| `/opt/AndroidRadioMM` | Original **Android** in-store player | `https://git.megamidia.site/tiago/AndroidRadioMM` | **Read-only behavior research.** Do not “improve” it to match desktop. When desktop is synced to Android behavior, **update this file** (section below). | +| `/opt/AndroidRadioMM` | Original **Android** in-store player | `origin` = `https://git.bvps.com.br/tiago/AndroidRadioMM-Private.git` (default push). `megamidia` = `https://git.megamidia.site/tiago/AndroidRadioMM` (push only when the current message names it). See **`CLAUDE.md`**. | Behavior research + authorized scheduler-align work. When desktop is synced to Android behavior, **update this file** (section below). | | `/opt/golang-tests/remote-control` | Companion (phone canvas + web users) | local `new-remote-control-app-basic` | Human WSS `:60210`, painel `:60200`. Not the player WSS. | | `/opt/wss-playerapp-server` | Older WSS clone | `https://git.megamidia.site/tiago/AndroidRadio-WSS-Server.git` | **Not** the live devel WSS. **Not** prod. Ignore unless you are explicitly comparing origin. | @@ -125,7 +125,9 @@ Load: `DOTENV_FILE=.env.devel` (or `load-env.js` when `NODE_ENV` is not producti ## Android behavior research (`/opt/AndroidRadioMM`) -Treat the Kotlin tree as the **matching** in-store client. Desktop work that claims “same as Android” must be checked here first (read-only). +Treat the Kotlin tree as the **matching** in-store client. Desktop work that claims “same as Android” must be checked here first (read-only unless the current message authorizes an Android edit). + +**Remotes (HARD, one-shot):** same working copy, two remotes. Push **only `origin`** (private `git.bvps.com.br` `AndroidRadioMM-Private.git`). Push **`megamidia`** (`git.megamidia.site`) only when the **current user message** names that remote. No `git push --all`. Full rule: project **`CLAUDE.md`**. Primary WSS/telemetry files: @@ -141,6 +143,7 @@ Primary WSS/telemetry files: | 2026-08 | Heartbeat + `playback_logs` shape | `WssClient.kt`, `PlaybackLogUploader.kt` | `protocol.go` `buildHeartbeat`, WSS `insertHeartbeat` / Timescale | | 2026-09-01 | Music template tags: **not** Android any-overlap. Go requires the file tag set to equal the template tag set so Boa `Livres_*` does not pick dual-tagged `Livres_*_DOM` (Dom Olívio) files. | `PlaylistScheduler.kt` still `template.tags.any { file.tags.contains }` | `core/template.go` `tagsEqual` in `matchingMusic` only; spots still `tagsOverlap` | | 2026-09-03 | Commercial: family stem hard skip; tag prefer-then-fallback (C stays C); distributed mini-window per file (jingles excluded on Go) | Keep `08cc0d30` mini-window; name/tag burst on `feat/align-commercial-go` (uncommitted until review) | `core/family.go`, `core/quota.go`, `core/commercial.go` 0.6.76 | +| 2026-09-08 | Music template tags: back to Android any-overlap (reverts the 2026-09-01 row). Boa and Amigão receive the same `/files` list from the server (tag overlap, `player-app-files.service.ts`), so no client rule can split them; which network gets a dual-tagged `Livres_*` + `Livres_*_DOM` file is decided by the tags in the manager. | `PlaylistScheduler.kt` `template.tags.any { file.tags.contains }` (same on every remote branch); DesktopRadioApp `TemplateSelector.kt` `template.tags.any { it in item.tags }` | `core/template.go` `tagsOverlap` in `matchingMusic`; `tagsEqual` removed | When you next sync a behavior from Android → Go/WSS, **append a row here**. Do not leave the match only in chat. diff --git a/PLAN-SCHEDULER-ALIGN.md b/PLAN-SCHEDULER-ALIGN.md index d1ac43a..bab71f6 100644 --- a/PLAN-SCHEDULER-ALIGN.md +++ b/PLAN-SCHEDULER-ALIGN.md @@ -77,7 +77,7 @@ Input: filename. 1. Lowercase; fold Portuguese accents (`á`→`a`, `ç`→`c`, …). 2. Replace non-letters/digits with spaces (`+`, `_`, `-`, punctuation). -3. Drop stop tokens: network (`amigao`, `parana`, `avenida`, `boa`, `domolivio`, `atacadista`, …), kind (`institucional`, `anun`, `anuncio`, `ofertas`, `setores`, `saudacoes`, …), function words (`de`, `da`, `do`, `a`, `e`, …), 1–2 digit seq (`01`), 4-digit dates (`0408`, `1309`). +3. Drop stop tokens: network (`amigao`, `parana`, `avenida`, `boa`, `domolivio`, `atacadista`, …), kind (`institucional`, `institucinal`, `anun`, `anuncio`, `ofertas`, `setores`, `comercial`, `spot`, `promocoes`, `vinheta`, `sazonais`, `pontes`, `parceiro`, `saudacoes`, …), function words (`de`, `da`, `do`, `a`, `e`, …), 1–2 digit seq (`01`), 4-digit dates (`0408`, `1309`). 4. Remaining tokens in order. **Key = first two tokens**, or the single token if only one. 5. Match if keys are equal **or** one token list is a prefix of the other (so `jingle` matches `jingle churrascao`). Empty key never matches. diff --git a/app.go b/app.go index caa8d2e..6df7019 100644 --- a/app.go +++ b/app.go @@ -382,14 +382,26 @@ func (a *App) AnnounceCall(kind string, values map[string]string) error { return errors.New("texto recusado") } if prep.Err() != nil { - return errOverlayTimeout - } - var err error - finalText, finalSpeech, rewritten, err = decideCallRewrite(ok, false, draftText, draftSpeech, t, sp, values) - if err != nil { - a.audit("info", "call", "call.rewrite_fail", draftSpeech, 0, map[string]any{"kind": kind, "draft": draftText}) - return err + if kind == "livre" { + return errOverlayTimeout + } + finalText = applyCallArticles(kind, draftText, values) + finalSpeech = applyCallArticles(kind, draftSpeech, values) + } else { + var err error + finalText, finalSpeech, rewritten, err = decideCallRewrite(ok, false, draftText, draftSpeech, t, sp, values) + if err != nil { + a.audit("info", "call", "call.rewrite_fail", draftSpeech, 0, map[string]any{"kind": kind, "draft": draftText}) + return err + } + if !rewritten { + finalText = applyCallArticles(kind, finalText, values) + finalSpeech = applyCallArticles(kind, finalSpeech, values) + } } + } else { + finalText = applyCallArticles(kind, draftText, values) + finalSpeech = applyCallArticles(kind, draftSpeech, values) } view := a.deskView() typed := []string{} @@ -457,13 +469,9 @@ func (a *App) Timeline() []map[string]any { if a.rdb == nil { return out } - ident := a.rdb.LoadServerIdentity() rows := a.rdb.ListPlayLogs() for i := len(rows) - 1; i >= 0 && len(out) < playLogKeep; i-- { row := rows[i] - if ident.StoreID != 0 && row.StoreID != 0 && row.StoreID != ident.StoreID { - continue - } if strings.EqualFold(row.Outcome, "INTERRUPTED") { continue } diff --git a/calls.go b/calls.go index cedcce8..b4fda08 100644 --- a/calls.go +++ b/calls.go @@ -2,6 +2,7 @@ package main import ( "errors" + "regexp" "strings" "unicode" ) @@ -11,8 +12,8 @@ const ( callInternoM = "Atenção funcionário {funcionario}, favor comparecer ao setor de {setor}." callInternoF = "Atenção funcionária {funcionario}, favor comparecer ao setor de {setor}." callInternoPlain = "Atenção {funcionario}, favor comparecer ao setor de {setor}." - callParkTpl = "Atenção proprietário do veículo {modelo}, cor {cor}, placa {placa}, favor comparecer ao estacionamento." - callParkRepeat = "Proprietário do veículo {modelo}, cor {cor}, placa {placa}, favor comparecer ao estacionamento." + callParkTpl = "Atenção proprietário do veículo {modelo}, {cor}, placa {placa}, favor comparecer ao estacionamento." + callParkRepeat = "Proprietário do veículo {modelo}, {cor}, placa {placa}, favor comparecer ao estacionamento." maxCallVariants = 8 listLearnCap = 80 ) @@ -389,6 +390,59 @@ func keepRewritten(values map[string]string, speech string) bool { return true } +var callSectorPrep = map[string]string{ + "padaria": "à", + "peixaria": "à", + "gerencia": "à", + "fiscalizacao": "à", + "perfumaria": "à", + "mercearia": "à", + "limpeza": "à", + "acougue": "ao", + "hortifruti": "ao", + "deposito": "ao", + "estacionamento": "ao", + "sac": "ao", +} + +func foldSectorKey(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + repl := strings.NewReplacer( + "á", "a", "à", "a", "ã", "a", "â", "a", + "é", "e", "ê", "e", "í", "i", + "ó", "o", "ô", "o", "õ", "o", + "ú", "u", "ü", "u", "ç", "c", + ) + return repl.Replace(s) +} + +func applyCallArticles(kind, phrase string, values map[string]string) string { + if strings.TrimSpace(phrase) == "" { + return phrase + } + if kind == "estacionamento" { + return regexp.MustCompile(`(?i), cor `).ReplaceAllString(phrase, ", ") + } + if kind != "interno" { + return phrase + } + setor := strings.TrimSpace(values["setor"]) + if setor == "" { + return phrase + } + key := foldSectorKey(setor) + dest := "" + if strings.HasPrefix(key, "caixa") { + dest = "ao " + strings.ToLower(setor) + } else if prep, ok := callSectorPrep[key]; ok { + dest = prep + " " + strings.ToLower(setor) + } else { + return phrase + } + re := regexp.MustCompile(`(?i)ao setor de ` + regexp.QuoteMeta(setor)) + return re.ReplaceAllString(phrase, dest) +} + func shouldRewriteCall(kind, installPath string, hasSync bool) bool { if !hasSync || ttsOfflineForced(installPath) { return false diff --git a/calls_test.go b/calls_test.go index cad70ba..2509e98 100644 --- a/calls_test.go +++ b/calls_test.go @@ -8,6 +8,33 @@ import ( "testing" ) +func TestApplyCallArticles(t *testing.T) { + vals := map[string]string{"genero": "f", "funcionario": "Ana", "setor": "padaria"} + got := applyCallArticles("interno", "Atenção funcionária Ana, favor comparecer ao setor de padaria.", vals) + if got != "Atenção funcionária Ana, favor comparecer à padaria." { + t.Fatalf("padaria %q", got) + } + vals["setor"] = "açougue" + got = applyCallArticles("interno", "Atenção funcionário Pedro, favor comparecer ao setor de açougue.", vals) + if got != "Atenção funcionário Pedro, favor comparecer ao açougue." { + t.Fatalf("acougue %q", got) + } + vals["setor"] = "CAIXA 1" + got = applyCallArticles("interno", "Atenção funcionário João, favor comparecer ao setor de CAIXA 1.", vals) + if got != "Atenção funcionário João, favor comparecer ao caixa 1." { + t.Fatalf("caixa %q", got) + } + unknown := "Atenção funcionário João, favor comparecer ao setor de fiscalização xyz." + vals["setor"] = "fiscalização xyz" + if applyCallArticles("interno", unknown, vals) != unknown { + t.Fatal("unknown setor must stay") + } + park := applyCallArticles("estacionamento", "Atenção proprietário do veículo Gol, cor branco, placa ABC1D23, favor comparecer ao estacionamento.", map[string]string{"modelo": "Gol", "cor": "branco"}) + if park != "Atenção proprietário do veículo Gol, branco, placa ABC1D23, favor comparecer ao estacionamento." { + t.Fatalf("parking %q", park) + } +} + func TestShouldRewriteCallSkipsWhenOfflineMarker(t *testing.T) { root := t.TempDir() if shouldRewriteCall("interno", root, true) != true { @@ -108,18 +135,18 @@ func TestBuildCallEstacionamentoVania(t *testing.T) { text, speech := buildCall("estacionamento", map[string]string{ "modelo": "Gol", "cor": "prata", "placa": "abc1d23", }) - want := "Atenção proprietário do veículo Gol, cor prata, placa ABC1D23, favor comparecer ao estacionamento." + want := "Atenção proprietário do veículo Gol, prata, placa ABC1D23, favor comparecer ao estacionamento." if text != want { t.Fatalf("text=%q", text) } if !strings.Contains(speech, "placa A-B-C-1-D-2-3") { t.Fatalf("speech plate %q", speech) } - if !strings.Contains(speech, "Proprietário do veículo Gol, cor prata, placa A-B-C-1-D-2-3") { + if !strings.Contains(speech, "Proprietário do veículo Gol, prata, placa A-B-C-1-D-2-3") { t.Fatalf("speech repeat %q", speech) } - if strings.Contains(text, "Gol prata") { - t.Fatal("modelo and cor must be separate") + if strings.Contains(text, "cor ") { + t.Fatal("must not say cor") } } diff --git a/core/family.go b/core/family.go index c85bf6b..18e7ba5 100644 --- a/core/family.go +++ b/core/family.go @@ -142,10 +142,16 @@ func foldPT(s string) string { var familyStop = map[string]bool{ "amigao": true, "parana": true, "avenida": true, "boa": true, "domolivio": true, "dom": true, "olivio": true, "atacadista": true, - "institucional": true, "institucionais": true, + "institucional": true, "institucionais": true, "institucinal": true, "anun": true, "anuncio": true, "ofertas": true, "oferta": true, "setores": true, "setor": true, + "comercial": true, "comerciais": true, "spot": true, "spots": true, + "promocoes": true, "promocao": true, + "vinhetas": true, "vinheta": true, + "sazonais": true, "sazonal": true, + "pontes": true, "ponte": true, + "parceiro": true, "parceiros": true, "saudacoes": true, "saudacao": true, "a": true, "o": true, "as": true, "os": true, "de": true, "da": true, "do": true, "das": true, "dos": true, diff --git a/core/family_test.go b/core/family_test.go index 4218087..8a94e9b 100644 --- a/core/family_test.go +++ b/core/family_test.go @@ -56,3 +56,14 @@ func TestCommercialFamilyKeyJinglePrefix(t *testing.T) { t.Fatalf("jingle prefix %q %q", a, b) } } + +func TestCommercialFamilyKeyStripsKindNoise(t *testing.T) { + typo := CommercialFamilyKey("amigao - institucinal +Amigo Coleciona_02") + if typo != "amigo coleciona" { + t.Fatalf("institucinal typo %q", typo) + } + cartao := CommercialFamilyKey("amigao - comercial Cartão +Amigo") + if cartao != "cartao amigo" { + t.Fatalf("comercial %q", cartao) + } +} diff --git a/core/template.go b/core/template.go index 1e12bf2..02a2260 100644 --- a/core/template.go +++ b/core/template.go @@ -130,7 +130,7 @@ func matchingMusic(t TemplateSchedule, media []MediaSchedule, ctx EligibilityCon if !IsMusic(m.Kind) || !IsEligibleAt(m, ctx) { continue } - if len(t.Tags) == 0 || tagsEqual(t.Tags, m.Tags) { + if len(t.Tags) == 0 || tagsOverlap(t.Tags, m.Tags) { out = append(out, m) } } @@ -168,20 +168,3 @@ func tagsOverlap(a, b []string) bool { } return false } - -func tagsEqual(template, file []string) bool { - want := toSet(normalizeTags(template)) - if len(want) == 0 { - return true - } - got := toSet(normalizeTags(file)) - if len(got) != len(want) { - return false - } - for t := range want { - if !got[t] { - return false - } - } - return true -} diff --git a/core/template_test.go b/core/template_test.go index f2ccc44..5c8ed25 100644 --- a/core/template_test.go +++ b/core/template_test.go @@ -5,44 +5,107 @@ import ( "time" ) -func TestTagsEqualBoaVsDOM(t *testing.T) { +func TestTagsOverlapMusic(t *testing.T) { cases := []struct { name string template []string file []string want bool }{ - {"empty template matches any file", nil, []string{"Livres_Cantadas"}, true}, - {"boa exact", []string{"Livres_Cantadas"}, []string{"Livres_Cantadas"}, true}, - {"dom exact", []string{"Livres_Cantadas_DOM"}, []string{"Livres_Cantadas_DOM"}, true}, - {"boa template does not take DOM-only file", []string{"Livres_Cantadas"}, []string{"Livres_Cantadas_DOM"}, false}, - {"dom template does not take Boa-only file", []string{"Livres_Cantadas_DOM"}, []string{"Livres_Cantadas"}, false}, - {"dual file needs both tags on the template", []string{"Livres_Cantadas"}, []string{"Livres_Cantadas", "Livres_Cantadas_DOM"}, false}, - {"dual template matches dual file", []string{"Livres_Cantadas", "Livres_Cantadas_DOM"}, []string{"Livres_Cantadas_DOM", "Livres_Cantadas"}, true}, - {"instrumental boa vs dual", []string{"Livres_Instrumentais"}, []string{"Livres_Instrumentais", "Livres_Instrumentais_DOM"}, false}, - {"case fold still exact", []string{"Livres_Cantadas"}, []string{"livres_cantadas"}, true}, + {"same tag", []string{"Livres_Cantadas"}, []string{"Livres_Cantadas"}, true}, + {"case fold", []string{"Livres_Cantadas"}, []string{"livres_cantadas"}, true}, + {"dual file shares one tag", []string{"Livres_Instrumentais"}, []string{"Livres_Instrumentais", "Livres_Instrumentais_DOM"}, true}, + {"dual file reversed order", []string{"Livres_Instrumentais"}, []string{"Livres_Instrumentais_DOM", "Livres_Instrumentais"}, true}, + {"dom template takes dual file", []string{"Livres_Cantadas_DOM"}, []string{"Livres_Cantadas", "Livres_Cantadas_DOM"}, true}, + {"boa template rejects dom-only file", []string{"Livres_Cantadas"}, []string{"Livres_Cantadas_DOM"}, false}, + {"suffix is a different tag", []string{"Livres_Instrumentais"}, []string{"Livres_Instrumentais_01"}, false}, + {"file without tags", []string{"Livres_Cantadas"}, nil, false}, } for _, tc := range cases { - if got := tagsEqual(tc.template, tc.file); got != tc.want { - t.Fatalf("%s: tagsEqual(%v,%v)=%v want %v", tc.name, tc.template, tc.file, got, tc.want) + if got := tagsOverlap(tc.template, tc.file); got != tc.want { + t.Fatalf("%s: tagsOverlap(%v,%v)=%v want %v", tc.name, tc.template, tc.file, got, tc.want) } } } -func TestMatchingMusicRejectsDualNetworkTags(t *testing.T) { +// Live tag sets of the shared music library (managerdb, 2026-09-08). +func liveMusicLibrary() []MediaSchedule { + add := func(out []MediaSchedule, prefix string, n int, tags ...string) []MediaSchedule { + for i := 0; i < n; i++ { + out = append(out, MediaSchedule{ID: prefix + "-" + string(rune('a'+i%26)) + string(rune('0'+i/26)), Kind: "music", Tags: tags}) + } + return out + } + var media []MediaSchedule + media = add(media, "cant", 47, "Livres_Cantadas") + media = add(media, "cant-dual", 15, "Livres_Cantadas", "Livres_Cantadas_DOM") + media = add(media, "cant-dom", 20, "Livres_Cantadas_DOM") + media = add(media, "inst", 1, "Livres_Instrumentais") + media = add(media, "inst-dual", 4, "Livres_Instrumentais_DOM", "Livres_Instrumentais") + media = add(media, "inst-01", 12, "Livres_Instrumentais_01") + media = add(media, "inst-01-dual", 5, "Livres_Instrumentais_01", "Livres_Instrumentais_DOM") + media = add(media, "inst-dom", 13, "Livres_Instrumentais_DOM") + media = append(media, MediaSchedule{ID: "spot-decoy", Kind: "spot", Tags: []string{"Livres_Cantadas"}}) + return media +} + +func TestMatchingMusicAnyOverlap(t *testing.T) { + ctx := EligibilityContext{DateTime: parseTestNow(), ClockTrusted: true, CommercialWindow: DefaultCommercialWindow()} + media := liveMusicLibrary() + cases := []struct { + name string + tags []string + want int + }{ + {"amigao boa parana cantadas", []string{"Livres_Cantadas"}, 47 + 15}, + {"amigao parana instrumentais", []string{"Livres_Instrumentais"}, 1 + 4}, + {"dom cantadas", []string{"Livres_Cantadas_DOM"}, 15 + 20}, + {"dom instrumentais", []string{"Livres_Instrumentais_DOM"}, 4 + 5 + 13}, + {"instrumentais 01", []string{"Livres_Instrumentais_01"}, 12 + 5}, + {"empty template takes all music", nil, len(media) - 1}, + } + for _, tc := range cases { + got := matchingMusic(TemplateSchedule{Kind: "music", Tags: tc.tags}, media, ctx) + if len(got) != tc.want { + t.Fatalf("%s: pool %d want %d (%v)", tc.name, len(got), tc.want, idsOf(got)) + } + for _, m := range got { + if m.Kind != "music" { + t.Fatalf("%s: non-music %s in pool", tc.name, m.ID) + } + } + } +} + +func TestInstrumentalSlotRotatesThroughDualTaggedFiles(t *testing.T) { ctx := EligibilityContext{DateTime: parseTestNow(), ClockTrusted: true, CommercialWindow: DefaultCommercialWindow()} media := []MediaSchedule{ - {ID: "boa", Kind: "music", Tags: []string{"Livres_Instrumentais"}}, - {ID: "dom", Kind: "music", Tags: []string{"Livres_Instrumentais_DOM"}}, - {ID: "dual", Kind: "music", Tags: []string{"Livres_Instrumentais", "Livres_Instrumentais_DOM"}}, + {ID: "3472", Kind: "music", Tags: []string{"Livres_Instrumentais"}}, + {ID: "3475", Kind: "music", Tags: []string{"Livres_Instrumentais", "Livres_Instrumentais_DOM"}}, + {ID: "3490", Kind: "music", Tags: []string{"Livres_Instrumentais_DOM", "Livres_Instrumentais"}}, + {ID: "3500", Kind: "music", Tags: []string{"Livres_Instrumentais", "Livres_Instrumentais_DOM"}}, + {ID: "3522", Kind: "music", Tags: []string{"Livres_Instrumentais_DOM", "Livres_Instrumentais"}}, } - got := matchingMusic(TemplateSchedule{Kind: "music", Tags: []string{"Livres_Instrumentais"}}, media, ctx) - if len(got) != 1 || got[0].ID != "boa" { - t.Fatalf("boa pool %v", idsOf(got)) + pls := []PlaylistSchedule{{ + ID: "67", Active: true, + Templates: []TemplateSchedule{{ID: "40", Kind: "music", Tags: []string{"Livres_Instrumentais"}}}, + }} + cadence := CadenceFromPattern("M") + st := RotationState{} + seen := map[string]int{} + for i := 0; i < 20; i++ { + dec := Decide(st, pls, media, nil, ctx, cadence) + if dec == nil || dec.Media.Kind != "music" { + t.Fatalf("pick %d: %+v", i, dec) + } + seen[dec.Media.ID]++ + st = ConfirmStarted(dec.State, dec.Reservation.ID) + st, _ = ApplyOutcome(st, dec.Reservation.ID, OutcomeCompleted) } - got = matchingMusic(TemplateSchedule{Kind: "music", Tags: []string{"Livres_Instrumentais_DOM"}}, media, ctx) - if len(got) != 1 || got[0].ID != "dom" { - t.Fatalf("dom pool %v", idsOf(got)) + for _, m := range media { + if seen[m.ID] != 4 { + t.Fatalf("file %s played %d times in 20 picks, want 4 (%v)", m.ID, seen[m.ID], seen) + } } } diff --git a/persist_test.go b/persist_test.go index efbe5cd..ea22eac 100644 --- a/persist_test.go +++ b/persist_test.go @@ -195,6 +195,42 @@ func TestAckPlayLogsKeepsTwenty(t *testing.T) { } } +func TestAckPlayLogsKeepsAllWhenUnderCap(t *testing.T) { + dir := t.TempDir() + rdb, _, err := openRadioDB(dir, exeMasterKey()) + if err != nil { + t.Fatal(err) + } + defer rdb.Close() + now := time.Now() + var ids []int64 + for i := 0; i < 5; i++ { + if err := rdb.EnqueuePlayLog(PlayLogRow{ + PlayedAt: now.Add(time.Duration(i) * time.Second), + MediaID: fmt.Sprintf("%d", i), + Filename: fmt.Sprintf("u%d.mp3", i), + Kind: "music", + Outcome: "COMPLETED", + StoreID: 171, + }); err != nil { + t.Fatal(err) + } + } + for _, row := range rdb.ListPlayLogs() { + ids = append(ids, row.ID) + } + if err := rdb.AckPlayLogs(ids); err != nil { + t.Fatal(err) + } + kept := rdb.ListPlayLogs() + if len(kept) != 5 { + t.Fatalf("keep under cap got %d", len(kept)) + } + if n := rdb.ListUnackedPlayLogs(); len(n) != 0 { + t.Fatalf("unacked after ack %d", len(n)) + } +} + func TestTimelineLastTwentySkipsInterruptedAndHistoryJSON(t *testing.T) { dir := t.TempDir() rdb, _, err := openRadioDB(dir, exeMasterKey()) @@ -236,23 +272,23 @@ func TestTimelineLastTwentySkipsInterruptedAndHistoryJSON(t *testing.T) { } } -func TestTimelineSkipsOtherStore(t *testing.T) { +func TestTimelineKeepsOtherStore(t *testing.T) { dir := t.TempDir() rdb, _, err := openRadioDB(dir, exeMasterKey()) if err != nil { t.Fatal(err) } defer rdb.Close() - rdb.SetDeviceUID("4WAT7TQK") - if err := rdb.SaveServerIdentity(ServerIdentity{StoreID: 232, StoreName: "Lj_MKT"}); err != nil { + rdb.SetDeviceUID("FBDHXPZ1") + if err := rdb.SaveServerIdentity(ServerIdentity{StoreID: 171, StoreName: "Teste MegaMidia"}); err != nil { t.Fatal(err) } now := time.Now() - _ = rdb.EnqueuePlayLog(PlayLogRow{PlayedAt: now, MediaID: "1", Filename: "mine.mp3", Kind: "music", StoreID: 232}) - _ = rdb.EnqueuePlayLog(PlayLogRow{PlayedAt: now.Add(time.Second), MediaID: "2", Filename: "other.mp3", Kind: "music", StoreID: 1}) + _ = rdb.EnqueuePlayLog(PlayLogRow{PlayedAt: now, MediaID: "1", Filename: "parana.mp3", Kind: "music", StoreID: 238}) + _ = rdb.EnqueuePlayLog(PlayLogRow{PlayedAt: now.Add(time.Second), MediaID: "2", Filename: "amigao.mp3", Kind: "music", StoreID: 171}) a := &App{rdb: rdb, hasProgram: true, store: &Store{}} got := a.Timeline() - if len(got) != 1 || got[0]["title"] != "mine.mp3" { + if len(got) != 2 || got[0]["title"] != "amigao.mp3" || got[1]["title"] != "parana.mp3" { t.Fatalf("%v", got) } } diff --git a/protocol.go b/protocol.go index 1513d5b..4734a90 100644 --- a/protocol.go +++ b/protocol.go @@ -676,6 +676,7 @@ func (s *RadioSync) requestCatalogRefresh(conn *websocket.Conn) { for _, ep := range []string{"config", "playlist", "files", "desktopBranding"} { _ = s.getData(conn, ep) } + _ = s.getDeskAppUI(conn) } func (s *RadioSync) getData(conn *websocket.Conn, endpoint string) error { @@ -969,19 +970,20 @@ func (s *RadioSync) handle(ctx context.Context, conn *websocket.Conn, raw []byte if !s.storeSnapshot("config", root, payload) { break } - before := 0 + prev := ServerIdentity{} if s.rdb != nil { - before = s.rdb.LoadServerIdentity().StoreID + prev = s.rdb.LoadServerIdentity() } s.applyConfig(payload) - after := 0 + next := ServerIdentity{} if s.rdb != nil { - after = s.rdb.LoadServerIdentity().StoreID + next = s.rdb.LoadServerIdentity() } - if before == 0 && after > 0 { + if shouldReloadBoundPayloads(prev, next) { _ = s.getData(conn, "playlist") _ = s.getData(conn, "files") _ = s.getData(conn, "desktopBranding") + _ = s.getDeskAppUI(conn) } s.maybeHeartbeat(conn, false) if s.trace != nil { @@ -1276,6 +1278,16 @@ func identityRebound(prev, next ServerIdentity) bool { return false } +func shouldReloadBoundPayloads(prev, next ServerIdentity) bool { + if next.StoreID == 0 { + return false + } + if prev.StoreID == 0 { + return true + } + return identityRebound(prev, next) +} + func (s *RadioSync) applyConfig(raw []byte) { ident, ok := parseServerIdentity(raw) if !ok { diff --git a/protocol_test.go b/protocol_test.go index eb1e1e6..35d04f3 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -326,6 +326,24 @@ func TestIdentityReboundOnStoreOrNetworkChange(t *testing.T) { } } +func TestShouldReloadBoundPayloads(t *testing.T) { + empty := ServerIdentity{} + amigao := ServerIdentity{StoreID: 171, NetworkID: 31} + parana := ServerIdentity{StoreID: 238, NetworkID: 27} + if !shouldReloadBoundPayloads(empty, amigao) { + t.Fatal("first bind") + } + if shouldReloadBoundPayloads(amigao, amigao) { + t.Fatal("same store") + } + if !shouldReloadBoundPayloads(parana, amigao) { + t.Fatal("parana to amigao") + } + if shouldReloadBoundPayloads(amigao, empty) { + t.Fatal("cleared identity") + } +} + func TestApplyConfigStoreChangeClearsRotationAndSnapshots(t *testing.T) { dir := t.TempDir() rdb, _, err := openRadioDB(dir, exeMasterKey()) diff --git a/storedb.go b/storedb.go index 7daf768..b903986 100644 --- a/storedb.go +++ b/storedb.go @@ -935,7 +935,7 @@ func (r *RadioDB) AckPlayLogs(ids []int64) error { return err } } - if err := r.exec(`DELETE FROM playback_log WHERE acked != 0 AND id NOT IN (SELECT id FROM playback_log ORDER BY played_at DESC, id DESC LIMIT ?)`, playLogKeep); err != nil { + if err := r.exec(`DELETE FROM playback_log WHERE acked != 0 AND id NOT IN (SELECT id FROM (SELECT id FROM playback_log ORDER BY played_at DESC, id DESC LIMIT ?))`, playLogKeep); err != nil { return err } return r.persist()