From 6d004ac7ca143b10297ec6aec6b2173b2277886b Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sun, 29 Mar 2026 04:51:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=A8=AD=E5=AE=9A=E3=83=9B?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=83=AA=E3=83=AD=E3=83=BC=E3=83=89=E3=81=A8?= =?UTF-8?q?=E7=8A=B6=E6=85=8B=E6=B0=B8=E7=B6=9A=E5=8C=96=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.jsonの変更を再起動なしで自動反映する機能と、 配信者状態をdata/state.jsonに永続化して再起動時の 重複通知を防止する機能を追加。 --- .gitignore | 3 + cmd/stream-notifier/main.go | 5 +- internal/monitor/poller.go | 122 +++++++++++++++++++++++++++++++++-- internal/monitor/state.go | 124 ++++++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 921f9a5..412df03 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ dist/ logs/ *.log +# State data +data/ + # Config (contains secrets) config.json diff --git a/cmd/stream-notifier/main.go b/cmd/stream-notifier/main.go index e2e59cd..d0dd840 100644 --- a/cmd/stream-notifier/main.go +++ b/cmd/stream-notifier/main.go @@ -279,7 +279,10 @@ func startMonitor() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - poller := monitor.NewPoller(api, cfg, func(changes []monitor.DetectedChange, sc config.StreamerConfig) { + configPath := "./config.json" + statePath := "./data/state.json" + + poller := monitor.NewPoller(api, cfg, configPath, statePath, func(changes []monitor.DetectedChange, sc config.StreamerConfig) { for _, change := range changes { embed := discord.BuildEmbed(change) streamerInfo := discord.StreamerInfo{ diff --git a/internal/monitor/poller.go b/internal/monitor/poller.go index 0799971..81aef37 100644 --- a/internal/monitor/poller.go +++ b/internal/monitor/poller.go @@ -3,6 +3,7 @@ package monitor import ( "context" "log/slog" + "os" "strings" "time" @@ -15,18 +16,23 @@ type ChangeHandler func(changes []DetectedChange, streamerConfig config.Streamer // Poller は配信者の状態を定期的にポーリングし変更を検出する。 type Poller struct { - api *twitch.API - cfg *config.Config - onChanges ChangeHandler - stateManager *StateManager - userCache map[string]twitch.User + api *twitch.API + cfg *config.Config + configPath string + statePath string + onChanges ChangeHandler + stateManager *StateManager + userCache map[string]twitch.User + lastConfigMod time.Time } // NewPoller はPollerインスタンスを作成する。 -func NewPoller(api *twitch.API, cfg *config.Config, onChanges ChangeHandler) *Poller { +func NewPoller(api *twitch.API, cfg *config.Config, configPath string, statePath string, onChanges ChangeHandler) *Poller { return &Poller{ api: api, cfg: cfg, + configPath: configPath, + statePath: statePath, onChanges: onChanges, stateManager: NewStateManager(), userCache: make(map[string]twitch.User), @@ -35,6 +41,16 @@ func NewPoller(api *twitch.API, cfg *config.Config, onChanges ChangeHandler) *Po // Run はポーリングループを開始する。ctxがキャンセルされるまで実行する。 func (p *Poller) Run(ctx context.Context) error { + // 前回の状態を復元 + if err := p.stateManager.LoadFromFile(p.statePath); err != nil { + slog.Warn("状態ファイルの読み込みに失敗(新規起動として続行)", "error", err) + } else if p.stateManager.stateCount() > 0 { + slog.Info("前回の状態を復元しました", "streamers", p.stateManager.stateCount()) + } + + // configファイルの初期modtimeを記録 + p.updateConfigModTime() + if err := p.initializeUserCache(ctx); err != nil { return err } @@ -53,8 +69,12 @@ func (p *Poller) Run(ctx context.Context) error { select { case <-ctx.Done(): slog.Info("ポーリング停止") + p.saveState() return nil case <-ticker.C: + if newInterval := p.checkConfigReload(ctx); newInterval > 0 { + ticker.Reset(newInterval) + } p.poll(ctx) } } @@ -84,6 +104,94 @@ func (p *Poller) initializeUserCache(ctx context.Context) error { return nil } +// updateConfigModTime はconfigファイルの更新時刻を記録する。 +func (p *Poller) updateConfigModTime() { + info, err := os.Stat(p.configPath) + if err != nil { + return + } + p.lastConfigMod = info.ModTime() +} + +// checkConfigReload はconfigファイルの変更を検出しリロードする。 +// ポーリング間隔が変更された場合は新しいintervalを返す。変更なしは0を返す。 +func (p *Poller) checkConfigReload(ctx context.Context) time.Duration { + info, err := os.Stat(p.configPath) + if err != nil { + return 0 + } + + if !info.ModTime().After(p.lastConfigMod) { + return 0 + } + + p.lastConfigMod = info.ModTime() + + newCfg, err := config.Load(p.configPath) + if err != nil { + slog.Error("設定リロード失敗(現在の設定を維持)", "error", err) + return 0 + } + + oldInterval := p.cfg.Polling.IntervalSeconds + oldStreamers := make(map[string]bool) + for _, s := range p.cfg.Streamers { + oldStreamers[strings.ToLower(s.Username)] = true + } + + p.cfg = newCfg + + // 削除された配信者のキャッシュと状態をクリア + newStreamers := make(map[string]bool) + for _, s := range newCfg.Streamers { + newStreamers[strings.ToLower(s.Username)] = true + } + for name := range oldStreamers { + if !newStreamers[name] { + delete(p.userCache, name) + p.stateManager.DeleteState(name) + slog.Info("配信者を削除", "username", name) + } + } + + // 新規追加された配信者のユーザーキャッシュを取得 + var newUsernames []string + for _, s := range newCfg.Streamers { + key := strings.ToLower(s.Username) + if !oldStreamers[key] { + newUsernames = append(newUsernames, s.Username) + } + } + if len(newUsernames) > 0 { + users, err := p.api.GetUsers(ctx, newUsernames) + if err != nil { + slog.Error("新規配信者のユーザー情報取得失敗", "error", err) + } else { + for k, u := range users { + p.userCache[k] = u + slog.Info("配信者を追加", "username", u.DisplayName) + } + } + } + + slog.Info("設定をリロードしました", "streamers", len(newCfg.Streamers)) + + if newCfg.Polling.IntervalSeconds != oldInterval { + newInterval := time.Duration(newCfg.Polling.IntervalSeconds) * time.Second + slog.Info("ポーリング間隔を変更", "old", oldInterval, "new", newCfg.Polling.IntervalSeconds) + return newInterval + } + + return 0 +} + +// saveState は現在の状態をファイルに保存する。 +func (p *Poller) saveState() { + if err := p.stateManager.SaveToFile(p.statePath); err != nil { + slog.Error("状態の保存に失敗", "error", err) + } +} + // combineChanges はタイトル変更とゲーム変更を同時検出した場合に統合する。 func combineChanges(changes []DetectedChange) []DetectedChange { var titleChange, gameChange *DetectedChange @@ -274,4 +382,6 @@ func (p *Poller) poll(ctx context.Context) { for _, sc := range p.cfg.Streamers { p.processStreamer(ctx, sc, streams, channels) } + + p.saveState() } diff --git a/internal/monitor/state.go b/internal/monitor/state.go index b96e7d4..db9b71c 100644 --- a/internal/monitor/state.go +++ b/internal/monitor/state.go @@ -2,6 +2,10 @@ package monitor import ( + "encoding/json" + "fmt" + "os" + "path/filepath" "strings" "sync" ) @@ -63,3 +67,123 @@ func (sm *StateManager) HasState(username string) bool { _, ok := sm.states[strings.ToLower(username)] return ok } + +// stateCount は保持している状態数を返す。 +func (sm *StateManager) stateCount() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return len(sm.states) +} + +// DeleteState は指定ユーザー名の状態を削除する。 +func (sm *StateManager) DeleteState(username string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + delete(sm.states, strings.ToLower(username)) +} + +// persistedState はJSON永続化用の状態。 +type persistedState struct { + UserID string `json:"userId"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + ProfileImageURL string `json:"profileImageUrl"` + IsLive bool `json:"isLive"` + Title string `json:"title"` + GameID string `json:"gameId"` + GameName string `json:"gameName"` + StartedAt string `json:"startedAt,omitempty"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` + ViewerCount int `json:"viewerCount,omitempty"` +} + +func toPersistedState(s StreamerState) persistedState { + return persistedState{ + UserID: s.UserID, + Username: s.Username, + DisplayName: s.DisplayName, + ProfileImageURL: s.ProfileImageURL, + IsLive: s.IsLive, + Title: s.Title, + GameID: s.GameID, + GameName: s.GameName, + StartedAt: s.StartedAt, + ThumbnailURL: s.ThumbnailURL, + ViewerCount: s.ViewerCount, + } +} + +func fromPersistedState(p persistedState) StreamerState { + return StreamerState{ + UserID: p.UserID, + Username: p.Username, + DisplayName: p.DisplayName, + ProfileImageURL: p.ProfileImageURL, + IsLive: p.IsLive, + Title: p.Title, + GameID: p.GameID, + GameName: p.GameName, + StartedAt: p.StartedAt, + ThumbnailURL: p.ThumbnailURL, + ViewerCount: p.ViewerCount, + } +} + +// SaveToFile は全状態をJSONファイルに保存する。 +// 一時ファイルに書き込んでからリネームすることでアトミックに書き込む。 +func (sm *StateManager) SaveToFile(path string) error { + sm.mu.RLock() + persisted := make(map[string]persistedState, len(sm.states)) + for k, v := range sm.states { + persisted[k] = toPersistedState(v) + } + sm.mu.RUnlock() + + data, err := json.MarshalIndent(persisted, "", " ") + if err != nil { + return fmt.Errorf("状態のJSON変換に失敗: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("状態保存ディレクトリの作成に失敗: %w", err) + } + + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return fmt.Errorf("状態の一時ファイル書き込みに失敗: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("状態ファイルのリネームに失敗: %w", err) + } + + return nil +} + +// LoadFromFile はJSONファイルから状態を復元する。 +// ファイルが存在しない場合はエラーなしで空状態のまま返す。 +func (sm *StateManager) LoadFromFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("状態ファイルの読み込みに失敗: %w", err) + } + + var persisted map[string]persistedState + if err := json.Unmarshal(data, &persisted); err != nil { + return fmt.Errorf("状態ファイルのJSON解析に失敗: %w", err) + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + for k, v := range persisted { + sm.states[k] = fromPersistedState(v) + } + + return nil +} From f2bef18e478812129f2e540b7d8a43b43ab0797e Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sun, 29 Mar 2026 05:02:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20persistedState=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=83=BBdirty=20flag=E5=B0=8E=E5=85=A5=E3=83=BB?= =?UTF-8?q?=E5=8F=A5=E7=82=B9=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamerStateにJSONタグを直接追加しpersistedStateと変換関数を削除 - MkdirAllをRun初期化時に移動し毎ポーリングのsyscallを排除 - checkConfigReloadのmap構築を1パスに統合 - processStreamerにdirty flagを導入し無変更時の状態保存をスキップ - 全ファイルのコメント末尾の句点を除去 --- cmd/stream-notifier/main.go | 26 ++++---- internal/cli/cli.go | 46 +++++++------- internal/config/config.go | 34 +++++------ internal/discord/embed.go | 24 ++++---- internal/discord/webhook.go | 10 +-- internal/monitor/detector.go | 6 +- internal/monitor/poller.go | 90 ++++++++++++++------------- internal/monitor/state.go | 115 ++++++++++------------------------- internal/twitch/api.go | 14 ++--- internal/twitch/auth.go | 8 +-- internal/twitch/types.go | 14 ++--- 11 files changed, 170 insertions(+), 217 deletions(-) diff --git a/cmd/stream-notifier/main.go b/cmd/stream-notifier/main.go index d0dd840..2cb8c20 100644 --- a/cmd/stream-notifier/main.go +++ b/cmd/stream-notifier/main.go @@ -1,4 +1,4 @@ -// Package main はStream Notifierのエントリーポイントを提供する。 +// Package main はStream Notifierのエントリーポイントを提供する package main import ( @@ -40,7 +40,7 @@ var levelColors = map[slog.Level]string{ slog.LevelError: colorRed, } -// consoleHandler はANSI色付きのコンソール出力ハンドラ。 +// consoleHandler はANSI色付きのコンソール出力ハンドラ type consoleHandler struct { level slog.Level mu sync.Mutex @@ -99,7 +99,7 @@ func (h *consoleHandler) WithGroup(name string) slog.Handler { return h } -// fileHandler はJSON形式のファイル出力ハンドラ。 +// fileHandler はJSON形式のファイル出力ハンドラ type fileHandler struct { level slog.Level logDir string @@ -107,7 +107,7 @@ type fileHandler struct { ensured bool } -// ensureDir はログディレクトリを確保する。 +// ensureDir はログディレクトリを確保する func (h *fileHandler) ensureDir() { if h.ensured { return @@ -118,7 +118,7 @@ func (h *fileHandler) ensureDir() { h.ensured = true } -// getDateString はJST日付をYYYY-MM-DD形式で返す。 +// getDateString はJST日付をYYYY-MM-DD形式で返す func getDateString() string { jst := time.FixedZone("JST", 9*60*60) return time.Now().In(jst).Format("2006-01-02") @@ -184,12 +184,12 @@ func (h *fileHandler) WithGroup(name string) slog.Handler { return h } -// jsonMarshal はJSON marshalerのラッパー。 +// jsonMarshal はJSON marshalerのラッパー func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } -// multiHandler は複数のslog.Handlerに同時出力する。 +// multiHandler は複数のslog.Handlerに同時出力する type multiHandler struct { handlers []slog.Handler } @@ -230,7 +230,7 @@ func (m *multiHandler) WithGroup(name string) slog.Handler { return &multiHandler{handlers: handlers} } -// parseSlogLevel はログレベル文字列をslog.Levelに変換する。 +// parseSlogLevel はログレベル文字列をslog.Levelに変換する func parseSlogLevel(level string) slog.Level { switch level { case config.LogDebug: @@ -246,7 +246,7 @@ func parseSlogLevel(level string) slog.Level { } } -// setupLogger はslogのグローバルロガーをセットアップする。 +// setupLogger はslogのグローバルロガーをセットアップする func setupLogger(level string) { slogLevel := parseSlogLevel(level) @@ -266,7 +266,10 @@ func startMonitor() error { slog.Info("Stream Notifier 起動中...") - cfg, err := config.Load("./config.json") + const configPath = "./config.json" + const statePath = "./data/state.json" + + cfg, err := config.Load(configPath) if err != nil { return err } @@ -279,9 +282,6 @@ func startMonitor() error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - configPath := "./config.json" - statePath := "./data/state.json" - poller := monitor.NewPoller(api, cfg, configPath, statePath, func(changes []monitor.DetectedChange, sc config.StreamerConfig) { for _, change := range changes { embed := discord.BuildEmbed(change) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 86829f8..bfc8323 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,4 +1,4 @@ -// Package cli は設定管理CLIを提供する。 +// Package cli は設定管理CLIを提供する package cli import ( @@ -16,7 +16,7 @@ const configPath = "./config.json" var scanner *bufio.Scanner -// getScanner はstdin用のscannerを遅延初期化して返す。 +// getScanner はstdin用のscannerを遅延初期化して返す func getScanner() *bufio.Scanner { if scanner == nil { scanner = bufio.NewScanner(os.Stdin) @@ -24,7 +24,7 @@ func getScanner() *bufio.Scanner { return scanner } -// promptInput はプロンプトを表示しユーザー入力を取得する。 +// promptInput はプロンプトを表示しユーザー入力を取得する func promptInput(message string) string { fmt.Print(message) s := getScanner() @@ -34,12 +34,12 @@ func promptInput(message string) string { return "" } -// validateWebhookURL はWebhook URLの形式を検証する。 +// validateWebhookURL はWebhook URLの形式を検証する func validateWebhookURL(url string) bool { return strings.HasPrefix(url, config.WebhookURLPrefix) } -// getEnabledNotificationTypes は有効な通知タイプを文字列で返す。 +// getEnabledNotificationTypes は有効な通知タイプを文字列で返す func getEnabledNotificationTypes(n config.NotificationSettings) string { var types []string if n.Online { @@ -60,7 +60,7 @@ func getEnabledNotificationTypes(n config.NotificationSettings) string { return strings.Join(types, ", ") } -// findStreamer はユーザー名で配信者を検索する。 +// findStreamer はユーザー名で配信者を検索する func findStreamer(streamers []config.StreamerConfig, username string) *config.StreamerConfig { lower := strings.ToLower(username) for i := range streamers { @@ -71,7 +71,7 @@ func findStreamer(streamers []config.StreamerConfig, username string) *config.St return nil } -// findStreamerIndex はユーザー名で配信者のインデックスを検索する。 +// findStreamerIndex はユーザー名で配信者のインデックスを検索する func findStreamerIndex(streamers []config.StreamerConfig, username string) int { lower := strings.ToLower(username) for i := range streamers { @@ -82,7 +82,7 @@ func findStreamerIndex(streamers []config.StreamerConfig, username string) int { return -1 } -// defaultNotifications は全通知有効のデフォルト設定を返す。 +// defaultNotifications は全通知有効のデフォルト設定を返す func defaultNotifications() config.NotificationSettings { return config.NotificationSettings{ Online: true, @@ -92,7 +92,7 @@ func defaultNotifications() config.NotificationSettings { } } -// addStreamer は配信者を追加する。 +// addStreamer は配信者を追加する func addStreamer(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -131,7 +131,7 @@ func addStreamer(username string) { fmt.Printf("%s を追加しました\n", username) } -// removeStreamer は配信者を削除する。 +// removeStreamer は配信者を削除する func removeStreamer(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -153,7 +153,7 @@ func removeStreamer(username string) { fmt.Printf("%s を削除しました\n", username) } -// listStreamers は登録済み配信者一覧を表示する。 +// listStreamers は登録済み配信者一覧を表示する func listStreamers() { cfg, err := config.Load(configPath) if err != nil { @@ -172,7 +172,7 @@ func listStreamers() { } } -// addWebhook は配信者にWebhookを追加する。 +// addWebhook は配信者にWebhookを追加する func addWebhook(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -213,7 +213,7 @@ func addWebhook(username string) { fmt.Printf("%s にWebhookを追加しました (合計: %d件)\n", username, len(streamer.Webhooks)) } -// removeWebhook は配信者からWebhookを削除する。 +// removeWebhook は配信者からWebhookを削除する func removeWebhook(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -258,7 +258,7 @@ func removeWebhook(username string) { fmt.Printf("Webhookを削除しました (残り: %d件)\n", len(streamer.Webhooks)) } -// configureWebhook は配信者のWebhook通知設定を変更する。 +// configureWebhook は配信者のWebhook通知設定を変更する func configureWebhook(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -317,7 +317,7 @@ func configureWebhook(username string) { fmt.Printf("\nWebhook %d の設定を更新しました\n", index+1) } -// parseYesNo は入力をboolに変換する。空文字列の場合は現在値を返す。 +// parseYesNo は入力をboolに変換し、空文字列の場合は現在値を返す func parseYesNo(input string, current bool) bool { if input == "" { return current @@ -325,7 +325,7 @@ func parseYesNo(input string, current bool) bool { return strings.ToLower(input) == "y" } -// boolToYN はboolをy/nに変換する。 +// boolToYN はboolをy/nに変換する func boolToYN(b bool) string { if b { return "y" @@ -333,7 +333,7 @@ func boolToYN(b bool) string { return "n" } -// truncateURL はURLを指定長で切り詰める。 +// truncateURL はURLを指定長で切り詰める func truncateURL(s string, maxLen int) string { if len(s) <= maxLen { return s @@ -341,12 +341,12 @@ func truncateURL(s string, maxLen int) string { return s[:maxLen] + "..." } -// getExeName は実行ファイル名を返す。 +// getExeName は実行ファイル名を返す func getExeName() string { return filepath.Base(os.Args[0]) } -// printUsage は使い方を表示する。 +// printUsage は使い方を表示する func printUsage() { exe := getExeName() fmt.Printf(` @@ -362,7 +362,7 @@ func printUsage() { `, exe, exe, exe, exe, exe, exe, exe, exe) } -// promptUsername はユーザー名を対話的に取得する。 +// promptUsername はユーザー名を対話的に取得する func promptUsername() string { username := promptInput("ユーザー名: ") if username == "" { @@ -378,7 +378,7 @@ type menuItem struct { action func() } -// interactiveMode は対話モードを実行する。 +// interactiveMode は対話モードを実行する func interactiveMode() { items := []menuItem{ {key: "1", label: "配信者を追加", action: func() { addStreamer(promptUsername()) }}, @@ -415,7 +415,7 @@ func interactiveMode() { os.Exit(1) } -// requireUsername はユーザー名引数が必須であることを検証する。 +// requireUsername はユーザー名引数が必須であることを検証する func requireUsername(args []string, index int) string { if index >= len(args) || args[index] == "" { fmt.Fprintln(os.Stderr, "エラー: ユーザー名を指定してください") @@ -424,7 +424,7 @@ func requireUsername(args []string, index int) string { return args[index] } -// Run はCLIを実行する。argsが空の場合は対話モードを起動する。 +// Run はCLIを実行し、argsが空の場合は対話モードを起動する func Run(args []string) { if len(args) == 0 { interactiveMode() diff --git a/internal/config/config.go b/internal/config/config.go index f3637e5..de9f46e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -// Package config はアプリケーション設定の読み込み・保存・バリデーションを提供する。 +// Package config はアプリケーション設定の読み込み・保存・バリデーションを提供する package config import ( @@ -8,7 +8,7 @@ import ( "strings" ) -// ChangeType は通知タイプを表す。 +// ChangeType は通知タイプを表す type ChangeType = string const ( @@ -19,7 +19,7 @@ const ( ChangeTitleAndGame ChangeType = "titleAndGameChange" ) -// LogLevel はログ出力レベルを表す。 +// LogLevel はログ出力レベルを表す type LogLevel = string const ( @@ -30,17 +30,17 @@ const ( ) const ( - // WebhookURLPrefix はDiscord Webhook URLの必須プレフィックス。 + // WebhookURLPrefix はDiscord Webhook URLの必須プレフィックス WebhookURLPrefix = "https://discord.com/api/webhooks/" - // ThumbnailWidth はサムネイル画像の幅。 + // ThumbnailWidth はサムネイル画像の幅 ThumbnailWidth = "440" - // ThumbnailHeight はサムネイル画像の高さ。 + // ThumbnailHeight はサムネイル画像の高さ ThumbnailHeight = "248" ) -// NotificationSettings は通知種別ごとの有効/無効設定。 +// NotificationSettings は通知種別ごとの有効/無効設定 type NotificationSettings struct { Online bool `json:"online"` Offline bool `json:"offline"` @@ -48,36 +48,36 @@ type NotificationSettings struct { GameChange bool `json:"gameChange"` } -// WebhookConfig はWebhook設定(URLと通知設定)。 +// WebhookConfig はWebhook設定(URLと通知設定) type WebhookConfig struct { Name string `json:"name,omitempty"` URL string `json:"url"` Notifications NotificationSettings `json:"notifications"` } -// StreamerConfig は配信者ごとの設定。 +// StreamerConfig は配信者ごとの設定 type StreamerConfig struct { Username string `json:"username"` Webhooks []WebhookConfig `json:"webhooks"` } -// TwitchConfig はTwitch API認証設定。 +// TwitchConfig はTwitch API認証設定 type TwitchConfig struct { ClientID string `json:"clientId"` ClientSecret string `json:"clientSecret"` } -// PollingConfig はポーリング間隔設定。 +// PollingConfig はポーリング間隔設定 type PollingConfig struct { IntervalSeconds int `json:"intervalSeconds"` } -// LogConfig はログ設定。 +// LogConfig はログ設定 type LogConfig struct { Level LogLevel `json:"level"` } -// Config はアプリケーション全体の設定。 +// Config はアプリケーション全体の設定 type Config struct { Twitch TwitchConfig `json:"twitch"` Polling PollingConfig `json:"polling"` @@ -85,7 +85,7 @@ type Config struct { Log LogConfig `json:"log"` } -// Load は指定パスからconfig.jsonを読み込みバリデーションする。 +// Load は指定パスからconfig.jsonを読み込みバリデーションする func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { @@ -104,7 +104,7 @@ func Load(path string) (*Config, error) { return &cfg, nil } -// Save は設定をJSON形式で指定パスに保存する。 +// Save は設定をJSON形式で指定パスに保存する func Save(path string, cfg *Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { @@ -113,7 +113,7 @@ func Save(path string, cfg *Config) error { return os.WriteFile(path, data, 0644) } -// Validate は設定のバリデーションを行う。 +// Validate は設定のバリデーションを行う func (c *Config) Validate() error { if c.Twitch.ClientID == "" { return fmt.Errorf("twitch.clientIdは必須です") @@ -152,7 +152,7 @@ func (c *Config) Validate() error { return nil } -// IsNotificationEnabled は変更タイプが通知設定で有効かどうかを判定する。 +// IsNotificationEnabled は変更タイプが通知設定で有効かどうかを判定する func IsNotificationEnabled(changeType ChangeType, n NotificationSettings) bool { switch changeType { case ChangeOnline: diff --git a/internal/discord/embed.go b/internal/discord/embed.go index a19bf91..97588dd 100644 --- a/internal/discord/embed.go +++ b/internal/discord/embed.go @@ -1,4 +1,4 @@ -// Package discord はDiscord Webhook連携を提供する。 +// Package discord はDiscord Webhook連携を提供する package discord import ( @@ -10,30 +10,30 @@ import ( "github.com/yuu1111/StreamNotifier/internal/monitor" ) -// EmbedField はDiscord Embedのフィールド。 +// EmbedField はDiscord Embedのフィールド type EmbedField struct { Name string `json:"name"` Value string `json:"value"` Inline bool `json:"inline,omitempty"` } -// EmbedFooter はDiscord Embedのフッター。 +// EmbedFooter はDiscord Embedのフッター type EmbedFooter struct { Text string `json:"text"` } -// EmbedAuthor はDiscord Embedの作成者情報。 +// EmbedAuthor はDiscord Embedの作成者情報 type EmbedAuthor struct { Name string `json:"name"` IconURL string `json:"icon_url,omitempty"` } -// EmbedImage はDiscord Embedの画像。 +// EmbedImage はDiscord Embedの画像 type EmbedImage struct { URL string `json:"url"` } -// Embed はDiscord Embed構造。 +// Embed はDiscord Embed構造 type Embed struct { Title string `json:"title"` Description string `json:"description,omitempty"` @@ -63,14 +63,14 @@ var titleMap = map[string]string{ config.ChangeTitleAndGame: "タイトル・ゲーム変更", } -// changeEventTypes はタイトル/ゲーム変更系のイベント種別。 +// changeEventTypes はタイトル/ゲーム変更系のイベント種別 var changeEventTypes = map[string]bool{ config.ChangeTitleChange: true, config.ChangeGameChange: true, config.ChangeTitleAndGame: true, } -// formatElapsedTime は配信開始からの経過時間を日本語でフォーマットする。 +// formatElapsedTime は配信開始からの経過時間を日本語でフォーマットする func formatElapsedTime(startedAt string) string { start, err := time.Parse(time.RFC3339, startedAt) if err != nil { @@ -96,7 +96,7 @@ func formatElapsedTime(startedAt string) string { return fmt.Sprintf("%d時間%d分前から配信中", hours, mins) } -// formatDuration は配信時間をフォーマットする。 +// formatDuration は配信時間をフォーマットする func formatDuration(startedAt string) string { start, err := time.Parse(time.RFC3339, startedAt) if err != nil { @@ -114,13 +114,13 @@ func formatDuration(startedAt string) string { return fmt.Sprintf("%d時間%d分", hours, mins) } -// formatTimeJST は時刻をJST HH:MM形式にフォーマットする。 +// formatTimeJST は時刻をJST HH:MM形式にフォーマットする func formatTimeJST(t time.Time) string { jst := time.FixedZone("JST", 9*60*60) return t.In(jst).Format("15:04") } -// orDefault は空文字列の場合にデフォルト値を返す。 +// orDefault は空文字列の場合にデフォルト値を返す func orDefault(s, defaultVal string) string { if s == "" { return defaultVal @@ -128,7 +128,7 @@ func orDefault(s, defaultVal string) string { return s } -// BuildEmbed は変更情報からDiscord Embedを構築する。 +// BuildEmbed は変更情報からDiscord Embedを構築する func BuildEmbed(change monitor.DetectedChange) Embed { state := change.CurrentState channelURL := "https://twitch.tv/" + state.Username diff --git a/internal/discord/webhook.go b/internal/discord/webhook.go index ebc5aa9..29e8dfa 100644 --- a/internal/discord/webhook.go +++ b/internal/discord/webhook.go @@ -12,20 +12,20 @@ import ( "time" ) -// WebhookPayload はDiscord Webhookのペイロード。 +// WebhookPayload はDiscord Webhookのペイロード type WebhookPayload struct { Embeds []Embed `json:"embeds"` Username string `json:"username,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } -// StreamerInfo は配信者情報(Webhook表示用)。 +// StreamerInfo は配信者情報(Webhook表示用) type StreamerInfo struct { DisplayName string ProfileImageURL string } -// SendWebhook は単一のWebhookにEmbedを送信する。 +// SendWebhook は単一のWebhookにEmbedを送信する func SendWebhook(ctx context.Context, webhookURL string, embed Embed, streamer StreamerInfo) error { payload := WebhookPayload{ Embeds: []Embed{embed}, @@ -64,7 +64,7 @@ func SendWebhook(ctx context.Context, webhookURL string, embed Embed, streamer S return nil } -// SendToMultipleWebhooks は複数のWebhookにEmbedを並列送信する。 +// SendToMultipleWebhooks は複数のWebhookにEmbedを並列送信する func SendToMultipleWebhooks(ctx context.Context, webhookURLs []string, embed Embed, streamer StreamerInfo) { var wg sync.WaitGroup for i, url := range webhookURLs { @@ -82,7 +82,7 @@ func SendToMultipleWebhooks(ctx context.Context, webhookURLs []string, embed Emb wg.Wait() } -// truncate は文字列を指定長で切り詰める。 +// truncate は文字列を指定長で切り詰める func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/internal/monitor/detector.go b/internal/monitor/detector.go index 6183f0b..4e8ab1e 100644 --- a/internal/monitor/detector.go +++ b/internal/monitor/detector.go @@ -4,7 +4,7 @@ import ( "github.com/yuu1111/StreamNotifier/internal/config" ) -// DetectedChange は検出された変更イベントを表す。 +// DetectedChange は検出された変更イベントを表す type DetectedChange struct { Type config.ChangeType Streamer string @@ -20,8 +20,8 @@ type DetectedChange struct { CurrentState StreamerState } -// DetectChanges は新旧状態を比較して変更を検出する。 -// oldStateがnilの場合は初回ポーリングとして空スライスを返す。 +// DetectChanges は新旧状態を比較して変更を検出する +// oldStateがnilの場合は初回ポーリングとして空スライスを返す func DetectChanges(oldState *StreamerState, newState StreamerState) []DetectedChange { if oldState == nil { return nil diff --git a/internal/monitor/poller.go b/internal/monitor/poller.go index 81aef37..2d3c34c 100644 --- a/internal/monitor/poller.go +++ b/internal/monitor/poller.go @@ -2,8 +2,10 @@ package monitor import ( "context" + "fmt" "log/slog" "os" + "path/filepath" "strings" "time" @@ -11,22 +13,22 @@ import ( "github.com/yuu1111/StreamNotifier/internal/twitch" ) -// ChangeHandler は変更検出時に呼び出されるコールバック型。 +// ChangeHandler は変更検出時に呼び出されるコールバック型 type ChangeHandler func(changes []DetectedChange, streamerConfig config.StreamerConfig) -// Poller は配信者の状態を定期的にポーリングし変更を検出する。 +// Poller は配信者の状態を定期的にポーリングし変更を検出する type Poller struct { - api *twitch.API - cfg *config.Config - configPath string - statePath string - onChanges ChangeHandler - stateManager *StateManager - userCache map[string]twitch.User - lastConfigMod time.Time + api *twitch.API + cfg *config.Config + configPath string + statePath string + onChanges ChangeHandler + stateManager *StateManager + userCache map[string]twitch.User + lastConfigMod time.Time } -// NewPoller はPollerインスタンスを作成する。 +// NewPoller はPollerインスタンスを作成する func NewPoller(api *twitch.API, cfg *config.Config, configPath string, statePath string, onChanges ChangeHandler) *Poller { return &Poller{ api: api, @@ -39,16 +41,18 @@ func NewPoller(api *twitch.API, cfg *config.Config, configPath string, statePath } } -// Run はポーリングループを開始する。ctxがキャンセルされるまで実行する。 +// Run はポーリングループを開始し、ctxがキャンセルされるまで実行する func (p *Poller) Run(ctx context.Context) error { - // 前回の状態を復元 + if err := os.MkdirAll(filepath.Dir(p.statePath), 0755); err != nil { + return fmt.Errorf("状態保存ディレクトリの作成に失敗: %w", err) + } + if err := p.stateManager.LoadFromFile(p.statePath); err != nil { slog.Warn("状態ファイルの読み込みに失敗(新規起動として続行)", "error", err) } else if p.stateManager.stateCount() > 0 { slog.Info("前回の状態を復元しました", "streamers", p.stateManager.stateCount()) } - // configファイルの初期modtimeを記録 p.updateConfigModTime() if err := p.initializeUserCache(ctx); err != nil { @@ -80,7 +84,7 @@ func (p *Poller) Run(ctx context.Context) error { } } -// initializeUserCache はユーザー情報をキャッシュに読み込む。 +// initializeUserCache はユーザー情報をキャッシュに読み込む func (p *Poller) initializeUserCache(ctx context.Context) error { usernames := make([]string, len(p.cfg.Streamers)) for i, s := range p.cfg.Streamers { @@ -104,7 +108,7 @@ func (p *Poller) initializeUserCache(ctx context.Context) error { return nil } -// updateConfigModTime はconfigファイルの更新時刻を記録する。 +// updateConfigModTime はconfigファイルの更新時刻を記録する func (p *Poller) updateConfigModTime() { info, err := os.Stat(p.configPath) if err != nil { @@ -113,8 +117,8 @@ func (p *Poller) updateConfigModTime() { p.lastConfigMod = info.ModTime() } -// checkConfigReload はconfigファイルの変更を検出しリロードする。 -// ポーリング間隔が変更された場合は新しいintervalを返す。変更なしは0を返す。 +// checkConfigReload はconfigファイルの変更を検出しリロードする +// ポーリング間隔が変更された場合は新しいintervalを返し、変更なしは0を返す func (p *Poller) checkConfigReload(ctx context.Context) time.Duration { info, err := os.Stat(p.configPath) if err != nil { @@ -134,17 +138,21 @@ func (p *Poller) checkConfigReload(ctx context.Context) time.Duration { } oldInterval := p.cfg.Polling.IntervalSeconds - oldStreamers := make(map[string]bool) + oldStreamers := make(map[string]bool, len(p.cfg.Streamers)) for _, s := range p.cfg.Streamers { oldStreamers[strings.ToLower(s.Username)] = true } p.cfg = newCfg - // 削除された配信者のキャッシュと状態をクリア - newStreamers := make(map[string]bool) + newStreamers := make(map[string]bool, len(newCfg.Streamers)) + var newUsernames []string for _, s := range newCfg.Streamers { - newStreamers[strings.ToLower(s.Username)] = true + key := strings.ToLower(s.Username) + newStreamers[key] = true + if !oldStreamers[key] { + newUsernames = append(newUsernames, s.Username) + } } for name := range oldStreamers { if !newStreamers[name] { @@ -153,15 +161,6 @@ func (p *Poller) checkConfigReload(ctx context.Context) time.Duration { slog.Info("配信者を削除", "username", name) } } - - // 新規追加された配信者のユーザーキャッシュを取得 - var newUsernames []string - for _, s := range newCfg.Streamers { - key := strings.ToLower(s.Username) - if !oldStreamers[key] { - newUsernames = append(newUsernames, s.Username) - } - } if len(newUsernames) > 0 { users, err := p.api.GetUsers(ctx, newUsernames) if err != nil { @@ -185,14 +184,14 @@ func (p *Poller) checkConfigReload(ctx context.Context) time.Duration { return 0 } -// saveState は現在の状態をファイルに保存する。 +// saveState は現在の状態をファイルに保存する func (p *Poller) saveState() { if err := p.stateManager.SaveToFile(p.statePath); err != nil { slog.Error("状態の保存に失敗", "error", err) } } -// combineChanges はタイトル変更とゲーム変更を同時検出した場合に統合する。 +// combineChanges はタイトル変更とゲーム変更を同時検出した場合に統合する func combineChanges(changes []DetectedChange) []DetectedChange { var titleChange, gameChange *DetectedChange for i := range changes { @@ -227,7 +226,7 @@ func combineChanges(changes []DetectedChange) []DetectedChange { return append(result, combined) } -// buildStreamerState はAPIレスポンスから配信者状態を構築する。 +// buildStreamerState はAPIレスポンスから配信者状態を構築する func buildStreamerState(user twitch.User, stream *twitch.Stream, channel *twitch.Channel) StreamerState { state := StreamerState{ UserID: user.ID, @@ -253,7 +252,7 @@ func buildStreamerState(user twitch.User, stream *twitch.Stream, channel *twitch return state } -// collectOfflineUserIDs はオフライン配信者のユーザーIDを収集する。 +// collectOfflineUserIDs はオフライン配信者のユーザーIDを収集する func (p *Poller) collectOfflineUserIDs(streams map[string]twitch.Stream) []string { var ids []string for _, s := range p.cfg.Streamers { @@ -268,7 +267,7 @@ func (p *Poller) collectOfflineUserIDs(streams map[string]twitch.Stream) []strin return ids } -// attachVodInfo はOffline変更にVOD情報を付与する。 +// attachVodInfo はOffline変更にVOD情報を付与する func (p *Poller) attachVodInfo(ctx context.Context, changes []DetectedChange, userID string) { for i := range changes { if changes[i].Type != config.ChangeOffline { @@ -292,17 +291,17 @@ func (p *Poller) attachVodInfo(ctx context.Context, changes []DetectedChange, us } } -// processStreamer は単一配信者の変更を処理する。 +// processStreamer は単一配信者の変更を処理し、状態が変化した場合はtrueを返す func (p *Poller) processStreamer( ctx context.Context, sc config.StreamerConfig, streams map[string]twitch.Stream, channels map[string]twitch.Channel, -) { +) bool { key := strings.ToLower(sc.Username) user, ok := p.userCache[key] if !ok { - return + return false } var streamPtr *twitch.Stream @@ -349,10 +348,12 @@ func (p *Poller) processStreamer( p.onChanges(combined, sc) } + changed := isInitialPoll || len(combined) > 0 p.stateManager.UpdateState(key, newState) + return changed } -// poll は全配信者の状態をポーリングして変更を検出する。 +// poll は全配信者の状態をポーリングして変更を検出する func (p *Poller) poll(ctx context.Context) { usernames := make([]string, len(p.cfg.Streamers)) for i, s := range p.cfg.Streamers { @@ -379,9 +380,14 @@ func (p *Poller) poll(ctx context.Context) { channels = make(map[string]twitch.Channel) } + dirty := false for _, sc := range p.cfg.Streamers { - p.processStreamer(ctx, sc, streams, channels) + if p.processStreamer(ctx, sc, streams, channels) { + dirty = true + } } - p.saveState() + if dirty { + p.saveState() + } } diff --git a/internal/monitor/state.go b/internal/monitor/state.go index db9b71c..aec663c 100644 --- a/internal/monitor/state.go +++ b/internal/monitor/state.go @@ -1,44 +1,43 @@ -// Package monitor は配信者の状態監視と変化検出を提供する。 +// Package monitor は配信者の状態監視と変化検出を提供する package monitor import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "sync" ) -// StreamerState は配信者の現在の状態を表す。 +// StreamerState は配信者の現在の状態を表す type StreamerState struct { - UserID string - Username string - DisplayName string - ProfileImageURL string - IsLive bool - Title string - GameID string - GameName string - StartedAt string // ISO 8601 (配信中のみ) - ThumbnailURL string // 配信中のみ - ViewerCount int + UserID string `json:"userId"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + ProfileImageURL string `json:"profileImageUrl"` + IsLive bool `json:"isLive"` + Title string `json:"title"` + GameID string `json:"gameId"` + GameName string `json:"gameName"` + StartedAt string `json:"startedAt,omitempty"` // ISO 8601 (配信中のみ) + ThumbnailURL string `json:"thumbnailUrl,omitempty"` // 配信中のみ + ViewerCount int `json:"viewerCount,omitempty"` } -// StateManager は配信者状態をin-memoryで管理する。 +// StateManager は配信者状態をin-memoryで管理する type StateManager struct { mu sync.RWMutex states map[string]StreamerState } -// NewStateManager はStateManagerインスタンスを作成する。 +// NewStateManager はStateManagerインスタンスを作成する func NewStateManager() *StateManager { return &StateManager{ states: make(map[string]StreamerState), } } -// GetState は指定ユーザー名の状態を返す。存在しない場合はnilを返す。 +// GetState は指定ユーザー名の状態を返す。存在しない場合はnilを返す func (sm *StateManager) GetState(username string) *StreamerState { sm.mu.RLock() defer sm.mu.RUnlock() @@ -51,7 +50,7 @@ func (sm *StateManager) GetState(username string) *StreamerState { return &state } -// UpdateState は指定ユーザー名の状態を更新する。 +// UpdateState は指定ユーザー名の状態を更新する func (sm *StateManager) UpdateState(username string, state StreamerState) { sm.mu.Lock() defer sm.mu.Unlock() @@ -59,7 +58,7 @@ func (sm *StateManager) UpdateState(username string, state StreamerState) { sm.states[strings.ToLower(username)] = state } -// HasState は指定ユーザー名の状態が存在するか返す。 +// HasState は指定ユーザー名の状態が存在するか返す func (sm *StateManager) HasState(username string) bool { sm.mu.RLock() defer sm.mu.RUnlock() @@ -68,14 +67,14 @@ func (sm *StateManager) HasState(username string) bool { return ok } -// stateCount は保持している状態数を返す。 +// stateCount は保持している状態数を返す func (sm *StateManager) stateCount() int { sm.mu.RLock() defer sm.mu.RUnlock() return len(sm.states) } -// DeleteState は指定ユーザー名の状態を削除する。 +// DeleteState は指定ユーザー名の状態を削除する func (sm *StateManager) DeleteState(username string) { sm.mu.Lock() defer sm.mu.Unlock() @@ -83,73 +82,21 @@ func (sm *StateManager) DeleteState(username string) { delete(sm.states, strings.ToLower(username)) } -// persistedState はJSON永続化用の状態。 -type persistedState struct { - UserID string `json:"userId"` - Username string `json:"username"` - DisplayName string `json:"displayName"` - ProfileImageURL string `json:"profileImageUrl"` - IsLive bool `json:"isLive"` - Title string `json:"title"` - GameID string `json:"gameId"` - GameName string `json:"gameName"` - StartedAt string `json:"startedAt,omitempty"` - ThumbnailURL string `json:"thumbnailUrl,omitempty"` - ViewerCount int `json:"viewerCount,omitempty"` -} - -func toPersistedState(s StreamerState) persistedState { - return persistedState{ - UserID: s.UserID, - Username: s.Username, - DisplayName: s.DisplayName, - ProfileImageURL: s.ProfileImageURL, - IsLive: s.IsLive, - Title: s.Title, - GameID: s.GameID, - GameName: s.GameName, - StartedAt: s.StartedAt, - ThumbnailURL: s.ThumbnailURL, - ViewerCount: s.ViewerCount, - } -} - -func fromPersistedState(p persistedState) StreamerState { - return StreamerState{ - UserID: p.UserID, - Username: p.Username, - DisplayName: p.DisplayName, - ProfileImageURL: p.ProfileImageURL, - IsLive: p.IsLive, - Title: p.Title, - GameID: p.GameID, - GameName: p.GameName, - StartedAt: p.StartedAt, - ThumbnailURL: p.ThumbnailURL, - ViewerCount: p.ViewerCount, - } -} - -// SaveToFile は全状態をJSONファイルに保存する。 -// 一時ファイルに書き込んでからリネームすることでアトミックに書き込む。 +// SaveToFile は全状態をJSONファイルに保存する +// 一時ファイルに書き込んでからリネームすることでアトミックに書き込む func (sm *StateManager) SaveToFile(path string) error { sm.mu.RLock() - persisted := make(map[string]persistedState, len(sm.states)) + snapshot := make(map[string]StreamerState, len(sm.states)) for k, v := range sm.states { - persisted[k] = toPersistedState(v) + snapshot[k] = v } sm.mu.RUnlock() - data, err := json.MarshalIndent(persisted, "", " ") + data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return fmt.Errorf("状態のJSON変換に失敗: %w", err) } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("状態保存ディレクトリの作成に失敗: %w", err) - } - tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, data, 0644); err != nil { return fmt.Errorf("状態の一時ファイル書き込みに失敗: %w", err) @@ -162,8 +109,8 @@ func (sm *StateManager) SaveToFile(path string) error { return nil } -// LoadFromFile はJSONファイルから状態を復元する。 -// ファイルが存在しない場合はエラーなしで空状態のまま返す。 +// LoadFromFile はJSONファイルから状態を復元する +// ファイルが存在しない場合はエラーなしで空状態のまま返す func (sm *StateManager) LoadFromFile(path string) error { data, err := os.ReadFile(path) if err != nil { @@ -173,16 +120,16 @@ func (sm *StateManager) LoadFromFile(path string) error { return fmt.Errorf("状態ファイルの読み込みに失敗: %w", err) } - var persisted map[string]persistedState - if err := json.Unmarshal(data, &persisted); err != nil { + var loaded map[string]StreamerState + if err := json.Unmarshal(data, &loaded); err != nil { return fmt.Errorf("状態ファイルのJSON解析に失敗: %w", err) } sm.mu.Lock() defer sm.mu.Unlock() - for k, v := range persisted { - sm.states[k] = fromPersistedState(v) + for k, v := range loaded { + sm.states[k] = v } return nil diff --git a/internal/twitch/api.go b/internal/twitch/api.go index bc8e7c2..57ab7e5 100644 --- a/internal/twitch/api.go +++ b/internal/twitch/api.go @@ -14,18 +14,18 @@ import ( const helixBaseURL = "https://api.twitch.tv/helix" -// API はTwitch Helix APIクライアント。 +// API はTwitch Helix APIクライアント type API struct { auth *Auth clientID string } -// NewAPI はAPIインスタンスを作成する。 +// NewAPI はAPIインスタンスを作成する func NewAPI(auth *Auth, clientID string) *API { return &API{auth: auth, clientID: clientID} } -// request はAPIリクエストを実行しレスポンスデータを返す。 +// request はAPIリクエストを実行しレスポンスデータを返す func request[T any](ctx context.Context, a *API, endpoint string, params url.Values) ([]T, error) { token, err := a.auth.GetToken(ctx) if err != nil { @@ -66,7 +66,7 @@ func request[T any](ctx context.Context, a *API, endpoint string, params url.Val return apiResp.Data, nil } -// GetUsers はユーザー情報を取得する。返り値はlogin名(小文字)をキーとするmap。 +// GetUsers はユーザー情報を取得し、login名(小文字)をキーとするmapを返す func (a *API) GetUsers(ctx context.Context, logins []string) (map[string]User, error) { if len(logins) == 0 { return make(map[string]User), nil @@ -91,7 +91,7 @@ func (a *API) GetUsers(ctx context.Context, logins []string) (map[string]User, e return result, nil } -// GetStreams は配信中のストリーム情報を取得する。返り値はlogin名(小文字)をキーとするmap。 +// GetStreams は配信中のストリーム情報を取得し、login名(小文字)をキーとするmapを返す func (a *API) GetStreams(ctx context.Context, userLogins []string) (map[string]Stream, error) { if len(userLogins) == 0 { return make(map[string]Stream), nil @@ -116,7 +116,7 @@ func (a *API) GetStreams(ctx context.Context, userLogins []string) (map[string]S return result, nil } -// GetChannels はチャンネル情報を取得する。返り値はlogin名(小文字)をキーとするmap。 +// GetChannels はチャンネル情報を取得し、login名(小文字)をキーとするmapを返す func (a *API) GetChannels(ctx context.Context, broadcasterIDs []string) (map[string]Channel, error) { if len(broadcasterIDs) == 0 { return make(map[string]Channel), nil @@ -141,7 +141,7 @@ func (a *API) GetChannels(ctx context.Context, broadcasterIDs []string) (map[str return result, nil } -// GetLatestVod は最新のアーカイブVODを取得する。存在しない場合はnilを返す。 +// GetLatestVod は最新のアーカイブVODを取得し、存在しない場合はnilを返す func (a *API) GetLatestVod(ctx context.Context, userID string) (*Video, error) { params := url.Values{ "user_id": {userID}, diff --git a/internal/twitch/auth.go b/internal/twitch/auth.go index 136712e..bb9ca40 100644 --- a/internal/twitch/auth.go +++ b/internal/twitch/auth.go @@ -13,7 +13,7 @@ import ( "time" ) -// Auth はTwitch Client Credentials認証を管理する。 +// Auth はTwitch Client Credentials認証を管理する type Auth struct { clientID string clientSecret string @@ -23,7 +23,7 @@ type Auth struct { expiresAt time.Time } -// NewAuth はAuthインスタンスを作成する。 +// NewAuth はAuthインスタンスを作成する func NewAuth(clientID, clientSecret string) *Auth { return &Auth{ clientID: clientID, @@ -31,7 +31,7 @@ func NewAuth(clientID, clientSecret string) *Auth { } } -// GetToken は有効なアクセストークンを返す。期限切れ間近なら自動更新する。 +// GetToken は有効なアクセストークンを返し、期限切れ間近なら自動更新する func (a *Auth) GetToken(ctx context.Context) (string, error) { a.mu.Lock() defer a.mu.Unlock() @@ -44,7 +44,7 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return a.refreshToken(ctx) } -// refreshToken はClient Credentials Flowでトークンを新規取得する。 +// refreshToken はClient Credentials Flowでトークンを新規取得する func (a *Auth) refreshToken(ctx context.Context) (string, error) { slog.Debug("Twitchアクセストークンを取得中...") diff --git a/internal/twitch/types.go b/internal/twitch/types.go index 76f0e35..181256d 100644 --- a/internal/twitch/types.go +++ b/internal/twitch/types.go @@ -1,12 +1,12 @@ -// Package twitch はTwitch Helix APIクライアントを提供する。 +// Package twitch はTwitch Helix APIクライアントを提供する package twitch -// apiResponse はTwitch APIの共通レスポンス構造。 +// apiResponse はTwitch APIの共通レスポンス構造 type apiResponse[T any] struct { Data []T `json:"data"` } -// User はTwitchユーザー情報。 +// User はTwitchユーザー情報 type User struct { ID string `json:"id"` Login string `json:"login"` @@ -14,7 +14,7 @@ type User struct { ProfileImageURL string `json:"profile_image_url"` } -// Stream はTwitch配信情報。 +// Stream はTwitch配信情報 type Stream struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -28,7 +28,7 @@ type Stream struct { ThumbnailURL string `json:"thumbnail_url"` } -// Channel はTwitchチャンネル情報(オフライン時のタイトル/ゲーム取得用)。 +// Channel はTwitchチャンネル情報(オフライン時のタイトル/ゲーム取得用) type Channel struct { BroadcasterID string `json:"broadcaster_id"` BroadcasterLogin string `json:"broadcaster_login"` @@ -38,7 +38,7 @@ type Channel struct { Title string `json:"title"` } -// Video はTwitch VOD情報。 +// Video はTwitch VOD情報 type Video struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -50,7 +50,7 @@ type Video struct { ThumbnailURL string `json:"thumbnail_url"` } -// tokenResponse はOAuth2トークンレスポンス。 +// tokenResponse はOAuth2トークンレスポンス type tokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` From 908512c18a5708f154681048f42b28d7a6dc9363 Mon Sep 17 00:00:00 2001 From: yuu1111 Date: Sun, 29 Mar 2026 20:25:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E3=82=AA=E3=83=95=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E6=99=82=E3=81=AEtitle/game=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=83=90=E3=82=B0=E4=BF=AE=E6=AD=A3=E3=81=A8?= =?UTF-8?q?=E5=85=A8=E4=BD=93=E5=93=81=E8=B3=AA=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DetectChangesで両方IsLiveの場合のみtitle/game変更を検出するよう修正 - ensureDirがMkdirAll失敗時にdone扱いにしないよう修正 - time.FixedZoneをpackage-level変数に変更し毎回の生成を排除 - formatElapsedTime/formatDurationの共通ロジックを抽出 - 未使用のSendToMultipleWebhooksを削除 - findStreamerをfindStreamerIndexベースに統合 --- cmd/stream-notifier/main.go | 4 +++- internal/cli/cli.go | 21 +++++++++----------- internal/discord/embed.go | 38 +++++++++++++++++------------------- internal/discord/webhook.go | 19 ------------------ internal/monitor/detector.go | 37 +++++++++++++++++++---------------- 5 files changed, 50 insertions(+), 69 deletions(-) diff --git a/cmd/stream-notifier/main.go b/cmd/stream-notifier/main.go index 2cb8c20..5350cb0 100644 --- a/cmd/stream-notifier/main.go +++ b/cmd/stream-notifier/main.go @@ -107,6 +107,8 @@ type fileHandler struct { ensured bool } +var jst = time.FixedZone("JST", 9*60*60) + // ensureDir はログディレクトリを確保する func (h *fileHandler) ensureDir() { if h.ensured { @@ -114,13 +116,13 @@ func (h *fileHandler) ensureDir() { } if err := os.MkdirAll(h.logDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Failed to create log directory: %v\n", err) + return } h.ensured = true } // getDateString はJST日付をYYYY-MM-DD形式で返す func getDateString() string { - jst := time.FixedZone("JST", 9*60*60) return time.Now().In(jst).Format("2006-01-02") } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index bfc8323..44bcebe 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -60,18 +60,7 @@ func getEnabledNotificationTypes(n config.NotificationSettings) string { return strings.Join(types, ", ") } -// findStreamer はユーザー名で配信者を検索する -func findStreamer(streamers []config.StreamerConfig, username string) *config.StreamerConfig { - lower := strings.ToLower(username) - for i := range streamers { - if strings.ToLower(streamers[i].Username) == lower { - return &streamers[i] - } - } - return nil -} - -// findStreamerIndex はユーザー名で配信者のインデックスを検索する +// findStreamerIndex はユーザー名で配信者のインデックスを検索する(-1: 未検出) func findStreamerIndex(streamers []config.StreamerConfig, username string) int { lower := strings.ToLower(username) for i := range streamers { @@ -82,6 +71,14 @@ func findStreamerIndex(streamers []config.StreamerConfig, username string) int { return -1 } +// findStreamer はユーザー名で配信者を検索する +func findStreamer(streamers []config.StreamerConfig, username string) *config.StreamerConfig { + if i := findStreamerIndex(streamers, username); i >= 0 { + return &streamers[i] + } + return nil +} + // defaultNotifications は全通知有効のデフォルト設定を返す func defaultNotifications() config.NotificationSettings { return config.NotificationSettings{ diff --git a/internal/discord/embed.go b/internal/discord/embed.go index 97588dd..e672ee3 100644 --- a/internal/discord/embed.go +++ b/internal/discord/embed.go @@ -70,26 +70,31 @@ var changeEventTypes = map[string]bool{ config.ChangeTitleAndGame: true, } -// formatElapsedTime は配信開始からの経過時間を日本語でフォーマットする -func formatElapsedTime(startedAt string) string { +var jst = time.FixedZone("JST", 9*60*60) + +// elapsedHoursMinutes はRFC3339タイムスタンプからの経過時間を時・分で返す +func elapsedHoursMinutes(startedAt string) (hours, mins int, ok bool) { start, err := time.Parse(time.RFC3339, startedAt) if err != nil { - return "" + return 0, 0, false } - diff := time.Since(start) if diff < 0 { - return "" + return 0, 0, false } + total := int(diff.Minutes()) + return total / 60, total % 60, true +} - totalMinutes := int(diff.Minutes()) - if totalMinutes < 1 { +// formatElapsedTime は配信開始からの経過時間を日本語でフォーマットする +func formatElapsedTime(startedAt string) string { + hours, mins, ok := elapsedHoursMinutes(startedAt) + if !ok { + return "" + } + if hours == 0 && mins < 1 { return "たった今" } - - hours := totalMinutes / 60 - mins := totalMinutes % 60 - if hours == 0 { return fmt.Sprintf("%d分前から配信中", mins) } @@ -98,16 +103,10 @@ func formatElapsedTime(startedAt string) string { // formatDuration は配信時間をフォーマットする func formatDuration(startedAt string) string { - start, err := time.Parse(time.RFC3339, startedAt) - if err != nil { + hours, mins, ok := elapsedHoursMinutes(startedAt) + if !ok { return "不明" } - - diff := time.Since(start) - totalMinutes := int(diff.Minutes()) - hours := totalMinutes / 60 - mins := totalMinutes % 60 - if hours == 0 { return fmt.Sprintf("%d分", mins) } @@ -116,7 +115,6 @@ func formatDuration(startedAt string) string { // formatTimeJST は時刻をJST HH:MM形式にフォーマットする func formatTimeJST(t time.Time) string { - jst := time.FixedZone("JST", 9*60*60) return t.In(jst).Format("15:04") } diff --git a/internal/discord/webhook.go b/internal/discord/webhook.go index 29e8dfa..38b6289 100644 --- a/internal/discord/webhook.go +++ b/internal/discord/webhook.go @@ -8,7 +8,6 @@ import ( "io" "log/slog" "net/http" - "sync" "time" ) @@ -64,24 +63,6 @@ func SendWebhook(ctx context.Context, webhookURL string, embed Embed, streamer S return nil } -// SendToMultipleWebhooks は複数のWebhookにEmbedを並列送信する -func SendToMultipleWebhooks(ctx context.Context, webhookURLs []string, embed Embed, streamer StreamerInfo) { - var wg sync.WaitGroup - for i, url := range webhookURLs { - wg.Add(1) - go func(idx int, u string) { - defer wg.Done() - if err := SendWebhook(ctx, u, embed, streamer); err != nil { - slog.Error("Webhook送信エラー", - "index", idx+1, - "total", len(webhookURLs), - "error", err) - } - }(i, url) - } - wg.Wait() -} - // truncate は文字列を指定長で切り詰める func truncate(s string, maxLen int) string { if len(s) <= maxLen { diff --git a/internal/monitor/detector.go b/internal/monitor/detector.go index 4e8ab1e..779b508 100644 --- a/internal/monitor/detector.go +++ b/internal/monitor/detector.go @@ -46,24 +46,27 @@ func DetectChanges(oldState *StreamerState, newState StreamerState) []DetectedCh }) } - if oldState.Title != newState.Title && newState.Title != "" { - changes = append(changes, DetectedChange{ - Type: config.ChangeTitleChange, - Streamer: newState.Username, - OldValue: oldState.Title, - NewValue: newState.Title, - CurrentState: newState, - }) - } + // タイトル/ゲーム変更は配信中の場合のみ検出する + if oldState.IsLive && newState.IsLive { + if oldState.Title != newState.Title && newState.Title != "" { + changes = append(changes, DetectedChange{ + Type: config.ChangeTitleChange, + Streamer: newState.Username, + OldValue: oldState.Title, + NewValue: newState.Title, + CurrentState: newState, + }) + } - if oldState.GameID != newState.GameID { - changes = append(changes, DetectedChange{ - Type: config.ChangeGameChange, - Streamer: newState.Username, - OldValue: oldState.GameName, - NewValue: newState.GameName, - CurrentState: newState, - }) + if oldState.GameID != newState.GameID { + changes = append(changes, DetectedChange{ + Type: config.ChangeGameChange, + Streamer: newState.Username, + OldValue: oldState.GameName, + NewValue: newState.GameName, + CurrentState: newState, + }) + } } return changes