musicbrainz-provider #2

Merged
Mrixs merged 72 commits from musicbrainz-provider into master 2026-08-05 20:19:16 +00:00
4 changed files with 264 additions and 4 deletions
Showing only changes of commit 3af33bd728 - Show all commits

View File

@@ -107,10 +107,10 @@ name collisions.)
- [x] run tests - must pass before task 5 - [x] run tests - must pass before task 5
### Task 5: Notifier — Telegram sender + digest ### Task 5: Notifier — Telegram sender + digest
- [ ] define `Sender` interface (`Send(ctx, message string) error`) and a `telegramSender` using `TelegramConfig` (bot API `sendMessage`) - [x] define `Sender` interface (`Send(ctx, message string) error`) and a `telegramSender` using `TelegramConfig` (bot API `sendMessage`)
- [ ] add `FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string` (artist names + counts + Web UI link) - [x] add `FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string` (artist names + counts + Web UI link)
- [ ] write tests: digest formatting, sender failure handling (stub sender) - [x] write tests: digest formatting, sender failure handling (stub sender)
- [ ] run tests - must pass before task 6 - [x] run tests - must pass before task 6
### Task 6: Notifier — scheduler + sent-tracking ### Task 6: Notifier — scheduler + sent-tracking
- [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid - [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid

View File

@@ -0,0 +1,53 @@
package notifier
import (
"fmt"
"sort"
"strings"
"naviwatcher/internal/scanner"
)
// FormatDigest renders newly-found missing releases into a human-readable
// Telegram message grouped by artist, with per-artist counts and a link to
// the Web UI dashboard. It is deterministic: artists are sorted by name and
// releases within an artist are sorted by title.
func FormatDigest(missing []scanner.MissingRelease, uiBaseURL string) string {
if len(missing) == 0 {
return "NaviWatcher: no new missing releases found."
}
type entry struct {
title string
}
byArtist := make(map[string][]entry)
order := make([]string, 0)
for _, r := range missing {
if _, ok := byArtist[r.ArtistID]; !ok {
order = append(order, r.ArtistID)
}
byArtist[r.ArtistID] = append(byArtist[r.ArtistID], entry{title: r.Title})
}
// Stable ordering by ArtistID.
sort.Strings(order)
var b strings.Builder
fmt.Fprintf(&b, "NaviWatcher: %d new missing release(s) found:\n\n", len(missing))
for _, artistID := range order {
entries := byArtist[artistID]
titles := make([]string, 0, len(entries))
for _, e := range entries {
titles = append(titles, e.title)
}
sort.Strings(titles)
fmt.Fprintf(&b, "%s (%d):\n", artistID, len(titles))
for _, t := range titles {
fmt.Fprintf(&b, " - %s\n", t)
}
b.WriteString("\n")
}
if uiBaseURL != "" {
fmt.Fprintf(&b, "View details: %s\n", strings.TrimRight(uiBaseURL, "/"))
}
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -0,0 +1,129 @@
package notifier
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"naviwatcher/internal/scanner"
)
type stubSender struct {
sent []string
failErr error
}
func (s *stubSender) Send(ctx context.Context, message string) error {
if s.failErr != nil {
return s.failErr
}
s.sent = append(s.sent, message)
return nil
}
func TestFormatDigest_Empty(t *testing.T) {
got := FormatDigest(nil, "http://ui")
if got != "NaviWatcher: no new missing releases found." {
t.Fatalf("unexpected empty digest: %q", got)
}
}
func TestFormatDigest_GroupsByArtistAndCounts(t *testing.T) {
missing := []scanner.MissingRelease{
{ArtistID: "art-b", Title: "Zebra", RGID: "r3"},
{ArtistID: "art-a", Title: "Alpha", RGID: "r1"},
{ArtistID: "art-a", Title: "Beta", RGID: "r2"},
}
got := FormatDigest(missing, "http://localhost:8080/")
if !strings.Contains(got, "art-a (2):") {
t.Errorf("expected art-a with count 2, got:\n%s", got)
}
if !strings.Contains(got, "art-b (1):") {
t.Errorf("expected art-b with count 1, got:\n%s", got)
}
// art-a should be alphabetically before art-b.
if strings.Index(got, "art-a") > strings.Index(got, "art-b") {
t.Errorf("artists not sorted: got:\n%s", got)
}
// Releases within artist sorted: Alpha before Beta.
aIdx := strings.Index(got, "Alpha")
bIdx := strings.Index(got, "Beta")
if aIdx > bIdx {
t.Errorf("titles not sorted: got:\n%s", got)
}
if !strings.Contains(got, "View details: http://localhost:8080") {
t.Errorf("expected UI link, got:\n%s", got)
}
}
func TestFormatDigest_EmptyUIBaseURLOmitsLink(t *testing.T) {
missing := []scanner.MissingRelease{{ArtistID: "a", Title: "x", RGID: "r1"}}
got := FormatDigest(missing, "")
if strings.Contains(got, "View details:") {
t.Errorf("did not expect UI link when base URL empty: got:\n%s", got)
}
}
func TestTelegramSender_Success(t *testing.T) {
var gotReq sendMessageRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/botTOKEN/sendMessage" {
t.Errorf("unexpected path %q", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode: %v", err)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
s := &telegramSender{
httpClient: srv.Client(),
token: "TOKEN",
chatID: "CHAT",
baseURL: srv.URL,
}
if err := s.Send(context.Background(), "hello"); err != nil {
t.Fatalf("Send returned error: %v", err)
}
if gotReq.ChatID != "CHAT" || gotReq.Text != "hello" || !gotReq.DisableWebPagePreview {
t.Errorf("unexpected payload: %+v", gotReq)
}
}
func TestTelegramSender_Non2xxError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"description":"Unauthorized"}`))
}))
defer srv.Close()
s := &telegramSender{
httpClient: srv.Client(),
token: "TOKEN",
chatID: "CHAT",
baseURL: srv.URL,
}
err := s.Send(context.Background(), "hi")
if err == nil {
t.Fatal("expected error on non-2xx")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("expected status in error, got: %v", err)
}
}
func TestSenderInterfaceFailurePropagated(t *testing.T) {
// Verifies the Sender interface can be used by callers and failures surface.
want := errors.New("boom")
s := &stubSender{failErr: want}
err := s.Send(context.Background(), "msg")
if !errors.Is(err, want) {
t.Fatalf("expected wrapped error %v, got %v", want, err)
}
}

View File

@@ -0,0 +1,78 @@
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"naviwatcher/internal/config"
)
// Sender delivers a notification message to a destination (e.g. Telegram).
// It is an interface so the real HTTP bot can be swapped for a stub in tests.
type Sender interface {
Send(ctx context.Context, message string) error
}
// telegramSender sends messages via the Telegram Bot API sendMessage method.
type telegramSender struct {
httpClient *http.Client
token string
chatID string
baseURL string
}
// NewTelegramSender constructs a Sender that posts to the Telegram Bot API
// using the token and chat ID from the given TelegramConfig.
func NewTelegramSender(cfg config.TelegramConfig) *telegramSender {
return &telegramSender{
httpClient: &http.Client{Timeout: 30 * time.Second},
token: cfg.Token,
chatID: cfg.ChatID,
baseURL: "https://api.telegram.org",
}
}
// sendMessageRequest is the JSON payload for the Telegram sendMessage endpoint.
type sendMessageRequest struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
DisableWebPagePreview bool `json:"disable_web_page_preview"`
}
// Send posts the message to the configured Telegram chat. It returns an error
// if the request cannot be built/sent or the API responds with a non-2xx code.
func (s *telegramSender) Send(ctx context.Context, message string) error {
payload := sendMessageRequest{
ChatID: s.chatID,
Text: message,
DisableWebPagePreview: true,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
url := fmt.Sprintf("%s/bot%s/sendMessage", s.baseURL, s.token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build telegram request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send telegram message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}