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..5350cb0 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,20 +107,22 @@ type fileHandler struct { ensured bool } -// ensureDir はログディレクトリを確保する。 +var jst = time.FixedZone("JST", 9*60*60) + +// ensureDir はログディレクトリを確保する func (h *fileHandler) ensureDir() { if h.ensured { return } 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形式で返す。 +// 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 +186,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 +232,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 +248,7 @@ func parseSlogLevel(level string) slog.Level { } } -// setupLogger はslogのグローバルロガーをセットアップする。 +// setupLogger はslogのグローバルロガーをセットアップする func setupLogger(level string) { slogLevel := parseSlogLevel(level) @@ -266,7 +268,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,7 +284,7 @@ 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) { + 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/cli/cli.go b/internal/cli/cli.go index 86829f8..44bcebe 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,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,7 +71,15 @@ func findStreamerIndex(streamers []config.StreamerConfig, username string) int { return -1 } -// defaultNotifications は全通知有効のデフォルト設定を返す。 +// 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{ Online: true, @@ -92,7 +89,7 @@ func defaultNotifications() config.NotificationSettings { } } -// addStreamer は配信者を追加する。 +// addStreamer は配信者を追加する func addStreamer(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -131,7 +128,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 +150,7 @@ func removeStreamer(username string) { fmt.Printf("%s を削除しました\n", username) } -// listStreamers は登録済み配信者一覧を表示する。 +// listStreamers は登録済み配信者一覧を表示する func listStreamers() { cfg, err := config.Load(configPath) if err != nil { @@ -172,7 +169,7 @@ func listStreamers() { } } -// addWebhook は配信者にWebhookを追加する。 +// addWebhook は配信者にWebhookを追加する func addWebhook(username string) { cfg, err := config.Load(configPath) if err != nil { @@ -213,7 +210,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 +255,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 +314,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 +322,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 +330,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 +338,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 +359,7 @@ func printUsage() { `, exe, exe, exe, exe, exe, exe, exe, exe) } -// promptUsername はユーザー名を対話的に取得する。 +// promptUsername はユーザー名を対話的に取得する func promptUsername() string { username := promptInput("ユーザー名: ") if username == "" { @@ -378,7 +375,7 @@ type menuItem struct { action func() } -// interactiveMode は対話モードを実行する。 +// interactiveMode は対話モードを実行する func interactiveMode() { items := []menuItem{ {key: "1", label: "配信者を追加", action: func() { addStreamer(promptUsername()) }}, @@ -415,7 +412,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 +421,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..e672ee3 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,64 +63,62 @@ var titleMap = map[string]string{ config.ChangeTitleAndGame: "タイトル・ゲーム変更", } -// changeEventTypes はタイトル/ゲーム変更系のイベント種別。 +// changeEventTypes はタイトル/ゲーム変更系のイベント種別 var changeEventTypes = map[string]bool{ config.ChangeTitleChange: true, config.ChangeGameChange: true, 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) } return fmt.Sprintf("%d時間%d分前から配信中", hours, mins) } -// formatDuration は配信時間をフォーマットする。 +// 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) } 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 +126,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..38b6289 100644 --- a/internal/discord/webhook.go +++ b/internal/discord/webhook.go @@ -8,24 +8,23 @@ import ( "io" "log/slog" "net/http" - "sync" "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,25 +63,7 @@ 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 は文字列を指定長で切り詰める。 +// 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..779b508 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 @@ -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 diff --git a/internal/monitor/poller.go b/internal/monitor/poller.go index 0799971..2d3c34c 100644 --- a/internal/monitor/poller.go +++ b/internal/monitor/poller.go @@ -2,7 +2,10 @@ package monitor import ( "context" + "fmt" "log/slog" + "os" + "path/filepath" "strings" "time" @@ -10,31 +13,48 @@ 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 - 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 { +// NewPoller は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), } } -// 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()) + } + + p.updateConfigModTime() + if err := p.initializeUserCache(ctx); err != nil { return err } @@ -53,14 +73,18 @@ 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) } } } -// initializeUserCache はユーザー情報をキャッシュに読み込む。 +// initializeUserCache はユーザー情報をキャッシュに読み込む func (p *Poller) initializeUserCache(ctx context.Context) error { usernames := make([]string, len(p.cfg.Streamers)) for i, s := range p.cfg.Streamers { @@ -84,7 +108,90 @@ func (p *Poller) initializeUserCache(ctx context.Context) error { return nil } -// combineChanges はタイトル変更とゲーム変更を同時検出した場合に統合する。 +// 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, len(p.cfg.Streamers)) + for _, s := range p.cfg.Streamers { + oldStreamers[strings.ToLower(s.Username)] = true + } + + p.cfg = newCfg + + newStreamers := make(map[string]bool, len(newCfg.Streamers)) + var newUsernames []string + for _, s := range newCfg.Streamers { + key := strings.ToLower(s.Username) + newStreamers[key] = true + if !oldStreamers[key] { + newUsernames = append(newUsernames, s.Username) + } + } + for name := range oldStreamers { + if !newStreamers[name] { + delete(p.userCache, name) + p.stateManager.DeleteState(name) + slog.Info("配信者を削除", "username", name) + } + } + 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 for i := range changes { @@ -119,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, @@ -145,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 { @@ -160,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 { @@ -184,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 @@ -241,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 { @@ -271,7 +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 + } + } + + if dirty { + p.saveState() } } diff --git a/internal/monitor/state.go b/internal/monitor/state.go index b96e7d4..aec663c 100644 --- a/internal/monitor/state.go +++ b/internal/monitor/state.go @@ -1,40 +1,43 @@ -// Package monitor は配信者の状態監視と変化検出を提供する。 +// Package monitor は配信者の状態監視と変化検出を提供する package monitor import ( + "encoding/json" + "fmt" + "os" "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() @@ -47,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() @@ -55,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() @@ -63,3 +66,71 @@ 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)) +} + +// SaveToFile は全状態をJSONファイルに保存する +// 一時ファイルに書き込んでからリネームすることでアトミックに書き込む +func (sm *StateManager) SaveToFile(path string) error { + sm.mu.RLock() + snapshot := make(map[string]StreamerState, len(sm.states)) + for k, v := range sm.states { + snapshot[k] = v + } + sm.mu.RUnlock() + + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return fmt.Errorf("状態のJSON変換に失敗: %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 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 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"`