musicbrainz-provider #2
@@ -113,10 +113,10 @@ name collisions.)
|
|||||||
- [x] 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
|
- [x] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid
|
||||||
- [ ] add cron-based scheduler goroutine honoring `TelegramConfig.CronSchedule` (use a lightweight cron lib or robfig/cron); no-op if `Enabled=false`
|
- [x] add cron-based scheduler goroutine honoring `TelegramConfig.CronSchedule` (use a lightweight cron lib or robfig/cron); no-op if `Enabled=false`
|
||||||
- [ ] write tests: `NotifyOnce` marks sent and skips already-sent; scheduler parses cron and fires (inject fixed time / use every-minute for test)
|
- [x] write tests: `NotifyOnce` marks sent and skips already-sent; scheduler parses cron and fires (inject fixed time / use every-minute for test)
|
||||||
- [ ] run tests - must pass before task 7
|
- [x] run tests - must pass before task 7
|
||||||
|
|
||||||
### Task 7: Web UI — server + auth + dashboard
|
### Task 7: Web UI — server + auth + dashboard
|
||||||
- [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
|
- [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -10,6 +10,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lithammer/fuzzysearch v1.1.8
|
github.com/lithammer/fuzzysearch v1.1.8
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -4,6 +4,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
|
|||||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
|||||||
31
internal/notifier/cron_schedule.go
Normal file
31
internal/notifier/cron_schedule.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CronSchedule wraps a robfig/cron schedule to satisfy the notifier.Schedule
|
||||||
|
// interface used by StartScheduler. The spec follows the standard 5-field cron
|
||||||
|
// syntax (e.g. "0 9 * * *" for daily at 09:00 in the process local time).
|
||||||
|
type CronSchedule struct {
|
||||||
|
spec string
|
||||||
|
c cron.Schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCronSchedule parses a cron spec and returns a Schedule. An error is
|
||||||
|
// returned if the spec is not a valid cron expression.
|
||||||
|
func NewCronSchedule(spec string) (*CronSchedule, error) {
|
||||||
|
c, err := cron.ParseStandard(spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse cron schedule %q: %w", spec, err)
|
||||||
|
}
|
||||||
|
return &CronSchedule{spec: spec, c: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next time the schedule fires after t.
|
||||||
|
func (s *CronSchedule) Next(t time.Time) time.Time {
|
||||||
|
return s.c.Next(t)
|
||||||
|
}
|
||||||
125
internal/notifier/scheduler.go
Normal file
125
internal/notifier/scheduler.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
|
"naviwatcher/internal/database"
|
||||||
|
"naviwatcher/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotifyOnce queries for releases that have not yet been notified, builds a
|
||||||
|
// digest, sends it through the given Sender, and marks each release as sent.
|
||||||
|
// Releases already present in notifications_sent are excluded upstream by
|
||||||
|
// GetUnnotifiedReleases, so this is idempotent across runs.
|
||||||
|
//
|
||||||
|
// If there are no unnotified releases the digest reports "no new missing
|
||||||
|
// releases" and nothing is marked sent (there is nothing to mark).
|
||||||
|
//
|
||||||
|
// uiBaseURL is the externally-reachable base URL of the Web UI, appended to the
|
||||||
|
// digest so operators can jump to the dashboard.
|
||||||
|
func NotifyOnce(ctx context.Context, db *database.DB, sender Sender, cfg config.TelegramConfig, uiBaseURL string) (int, error) {
|
||||||
|
if sender == nil {
|
||||||
|
return 0, fmt.Errorf("notifier: sender must not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
unnotified, err := database.GetUnnotifiedReleases(db)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("notifier: query unnotified releases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := make([]scanner.MissingRelease, 0, len(unnotified))
|
||||||
|
for _, r := range unnotified {
|
||||||
|
missing = append(missing, scanner.MissingRelease{
|
||||||
|
RGID: r.RGID,
|
||||||
|
ArtistID: r.ArtistID,
|
||||||
|
Title: r.Title,
|
||||||
|
Type: r.Type,
|
||||||
|
ReleaseDate: r.ReleaseDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
message := FormatDigest(missing, uiBaseURL)
|
||||||
|
if err := sender.Send(ctx, message); err != nil {
|
||||||
|
return 0, fmt.Errorf("notifier: send digest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range unnotified {
|
||||||
|
if err := database.MarkNotificationSent(db, r.RGID); err != nil {
|
||||||
|
return 0, fmt.Errorf("notifier: mark sent for %s: %w", r.RGID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(unnotified), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule produces the next firing time strictly after the given time. It
|
||||||
|
// mirrors the robfig/cron Schedule interface so cron specs and simple
|
||||||
|
// interval-based schedules are interchangeable and testable.
|
||||||
|
type Schedule interface {
|
||||||
|
Next(time.Time) time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyFunc is the unit of work the scheduler runs on each firing. It mirrors
|
||||||
|
// the signature of NotifyOnce so the scheduler can be tested with a stub.
|
||||||
|
type notifyFunc func(ctx context.Context) error
|
||||||
|
|
||||||
|
// StartScheduler runs the notify function on a schedule until ctx is cancelled.
|
||||||
|
// It is no-op-safe: if enabled is false it returns immediately without starting
|
||||||
|
// a goroutine. Each firing runs in its own goroutine so a slow send does not
|
||||||
|
// delay the next scheduled tick; the scheduler still computes the next tick from
|
||||||
|
// the wall clock and does not drift.
|
||||||
|
//
|
||||||
|
// The schedule and notify function are injectable so tests can drive a fixed or
|
||||||
|
// frequent schedule without a real cron spec or Telegram server.
|
||||||
|
func StartScheduler(ctx context.Context, enabled bool, schedule Schedule, notify notifyFunc, now func() time.Time) {
|
||||||
|
if !enabled || schedule == nil || notify == nil {
|
||||||
|
log.Println("Notifier scheduler disabled or misconfigured; not starting.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
timer := time.NewTimer(0)
|
||||||
|
defer timer.Stop()
|
||||||
|
// Fire immediately on start (startup digest), then schedule subsequent runs.
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
var wait time.Duration
|
||||||
|
if first {
|
||||||
|
first = false
|
||||||
|
wait = 0
|
||||||
|
} else {
|
||||||
|
next := schedule.Next(now())
|
||||||
|
if next.IsZero() {
|
||||||
|
log.Println("Notifier schedule has no next fire; stopping scheduler.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wait = time.Until(next)
|
||||||
|
if wait < 0 {
|
||||||
|
wait = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Reset(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("Notifier scheduler stopped.")
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
go func() {
|
||||||
|
if err := notify(ctx); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Notifier run failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
238
internal/notifier/scheduler_test.go
Normal file
238
internal/notifier/scheduler_test.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package notifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
|
"naviwatcher/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixedSchedule is a test Schedule that fires a fixed duration after every call
|
||||||
|
// to Next, so a scheduler test can run deterministically without a real cron.
|
||||||
|
type fixedSchedule struct {
|
||||||
|
interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fixedSchedule) Next(t time.Time) time.Time {
|
||||||
|
return t.Add(f.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSender records messages and can be told to fail.
|
||||||
|
type collectSender struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
messages []string
|
||||||
|
failErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *collectSender) Send(ctx context.Context, message string) error {
|
||||||
|
if s.failErr != nil {
|
||||||
|
return s.failErr
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.messages = append(s.messages, message)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *collectSender) count() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRelease inserts an external_release row (with an artist) and optionally
|
||||||
|
// marks it as already notified. Returns the rgid.
|
||||||
|
func seedRelease(t *testing.T, db *database.DB, rgid, artistID string, notified bool) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.Conn().Exec(
|
||||||
|
"INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)",
|
||||||
|
artistID, "Test Artist "+artistID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed artist: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Conn().Exec(
|
||||||
|
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
rgid, artistID, "Release "+rgid, "album", "",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed release: %v", err)
|
||||||
|
}
|
||||||
|
if notified {
|
||||||
|
if err := database.MarkNotificationSent(db, rgid); err != nil {
|
||||||
|
t.Fatalf("mark sent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyOnce_SendsAndMarksSent(t *testing.T) {
|
||||||
|
db, err := database.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(): %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
seedRelease(t, db, "rgid-1", "artist-1", false)
|
||||||
|
seedRelease(t, db, "rgid-2", "artist-1", false)
|
||||||
|
|
||||||
|
sender := &collectSender{}
|
||||||
|
cfg := config.TelegramConfig{Enabled: true}
|
||||||
|
n, err := NotifyOnce(context.Background(), db, sender, cfg, "http://ui:8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NotifyOnce: %v", err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("expected 2 releases notified, got %d", n)
|
||||||
|
}
|
||||||
|
if sender.count() != 1 {
|
||||||
|
t.Fatalf("expected a single digest message, got %d", sender.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// After notifying, both should now be considered sent.
|
||||||
|
remaining, err := database.GetUnnotifiedReleases(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUnnotifiedReleases: %v", err)
|
||||||
|
}
|
||||||
|
if len(remaining) != 0 {
|
||||||
|
t.Fatalf("expected 0 unnotified after NotifyOnce, got %d", len(remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyOnce_SkipsAlreadySent(t *testing.T) {
|
||||||
|
db, err := database.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(): %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// One already-notified, one new.
|
||||||
|
seedRelease(t, db, "rgid-done", "artist-1", true)
|
||||||
|
seedRelease(t, db, "rgid-new", "artist-1", false)
|
||||||
|
|
||||||
|
sender := &collectSender{}
|
||||||
|
n, err := NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NotifyOnce: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("expected 1 newly notified release, got %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The already-sent one stays marked sent; the new one is now marked.
|
||||||
|
done, err := database.IsNotificationSent(db, "rgid-done")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsNotificationSent done: %v", err)
|
||||||
|
}
|
||||||
|
if !done {
|
||||||
|
t.Error("expected rgid-done to remain sent")
|
||||||
|
}
|
||||||
|
newsent, err := database.IsNotificationSent(db, "rgid-new")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsNotificationSent new: %v", err)
|
||||||
|
}
|
||||||
|
if !newsent {
|
||||||
|
t.Error("expected rgid-new to be marked sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyOnce_SendErrorNotMarked(t *testing.T) {
|
||||||
|
db, err := database.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(): %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
seedRelease(t, db, "rgid-1", "artist-1", false)
|
||||||
|
|
||||||
|
want := errors.New("send boom")
|
||||||
|
sender := &collectSender{failErr: want}
|
||||||
|
_, err = NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui")
|
||||||
|
if err == nil || !errors.Is(err, want) {
|
||||||
|
t.Fatalf("expected error %v, got %v", want, err)
|
||||||
|
}
|
||||||
|
// On send failure nothing should be marked sent.
|
||||||
|
sent, err := database.IsNotificationSent(db, "rgid-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsNotificationSent: %v", err)
|
||||||
|
}
|
||||||
|
if sent {
|
||||||
|
t.Error("release should NOT be marked sent when send fails")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyOnce_NilSender(t *testing.T) {
|
||||||
|
db, err := database.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New(): %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if _, err := NotifyOnce(context.Background(), db, nil, config.TelegramConfig{}, "http://ui"); err == nil {
|
||||||
|
t.Fatal("expected error for nil sender")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartScheduler_FiresOnSchedule(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var calls int
|
||||||
|
notify := func(ctx context.Context) error {
|
||||||
|
mu.Lock()
|
||||||
|
calls++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed 10ms interval schedule; injected now func is unused by fixedSchedule.
|
||||||
|
StartScheduler(ctx, true, fixedSchedule{interval: 10 * time.Millisecond}, notify, time.Now)
|
||||||
|
|
||||||
|
// Allow a few ticks (immediate fire + scheduled ones).
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
got := calls
|
||||||
|
mu.Unlock()
|
||||||
|
if got < 2 {
|
||||||
|
t.Fatalf("expected scheduler to fire at least twice, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartScheduler_DisabledNoOp(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fired := false
|
||||||
|
notify := func(ctx context.Context) error {
|
||||||
|
fired = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
StartScheduler(ctx, false, fixedSchedule{interval: time.Millisecond}, notify, time.Now)
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if fired {
|
||||||
|
t.Fatal("scheduler should not fire when disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronSchedule_ParsesAndNext(t *testing.T) {
|
||||||
|
s, err := NewCronSchedule("0 9 * * *")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCronSchedule: %v", err)
|
||||||
|
}
|
||||||
|
base := time.Date(2026, 7, 19, 10, 0, 0, 0, time.Local)
|
||||||
|
next := s.Next(base)
|
||||||
|
// After 10:00, the next 09:00 daily fire is the next day.
|
||||||
|
if next.Day() != 20 || next.Hour() != 9 {
|
||||||
|
t.Fatalf("expected next fire at 09:00 next day, got %v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronSchedule_InvalidSpec(t *testing.T) {
|
||||||
|
if _, err := NewCronSchedule("not a cron"); err == nil {
|
||||||
|
t.Fatal("expected error for invalid cron spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user