/* * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ package web import ( "axenov/iptv-checker/app/checker" "axenov/iptv-checker/app/config" "axenov/iptv-checker/app/inifile" "axenov/iptv-checker/app/playlist" "axenov/iptv-checker/app/utils" "log" "maps" "net/http" "os" "slices" "strconv" "sync" "time" "github.com/redis/go-redis/v9" ) // Server — веб-сервер для отображения плейлистов type Server struct { cfg *config.Config cache *redis.Client templates *TemplateManager iniCache inifile.IniFile iniLoaded time.Time memMu sync.RWMutex memCache map[string]playlist.Playlist } // NewServer создаёт новый экземпляр веб-сервера func NewServer(cfg *config.Config, cache *redis.Client) *Server { s := &Server{ cfg: cfg, cache: cache, memCache: make(map[string]playlist.Playlist), } tm, err := NewTemplateManager(cfg) if err != nil { log.Fatalf("Failed to load templates: %s", err) } s.templates = tm return s } // Start запускает веб-сервер func (s *Server) Start() { mux := http.NewServeMux() // API routes (specific, no conflicts) mux.HandleFunc("GET /api/playlists", s.handleAPIGetAll) mux.HandleFunc("GET /api/playlists/{code}", s.handleAPIGetOne) mux.HandleFunc("GET /api/playlists/{code}/channels", s.handleAPIGetChannels) mux.HandleFunc("GET /api/version", s.handleAPIVersion) mux.HandleFunc("GET /api/health", s.handleAPIHealth) mux.HandleFunc("GET /api/stats", s.handleAPIStats) // Static files mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer)) // OpenAPI spec + Swagger UI mux.HandleFunc("GET /api/openapi.json", s.handleOpenAPISpec) mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/api/", http.StatusMovedPermanently) }) mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer)) // All other routes handled by catch-all to avoid mux pattern conflicts mux.HandleFunc("GET /{$}", s.handleHome) mux.HandleFunc("GET /{path...}", s.handleRoute) handler := s.withMiddleware(mux) addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10) log.Printf("Starting web server on %s", addr) if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("Server failed: %s", err) } } // withMiddleware добавляет middleware: логирование, восстановление после паники func (s *Server) withMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() defer func() { if err := recover(); err != nil { log.Printf("PANIC: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr) }) } // loadIni загружает ini-файл (с кешированием на 30 секунд) func (s *Server) loadIni() (inifile.IniFile, error) { // перезагружаем раз в 30 секунд if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 { return s.iniCache, nil } ini, err := inifile.Init(s.cfg.App.Playlists) if err != nil { return inifile.IniFile{}, err } s.iniCache = ini s.iniLoaded = time.Now() return ini, nil } // getUpdatedAt возвращает время последнего изменения ini-файла func (s *Server) getUpdatedAt() string { path, err := utils.ExpandPath(s.cfg.App.Playlists) if err != nil { return "" } info, err := os.Stat(path) if err != nil { return "" } loc, err := time.LoadLocation(s.cfg.App.Timezone) if err != nil { loc = time.UTC } return info.ModTime().In(loc).Format("02.01.2006 15:04") } // getTimezoneLabel возвращает сокращённое обозначение часового пояса для отображения. func (s *Server) getTimezoneLabel() string { return s.cfg.App.Timezone } // CheckOptions — параметры фоновой проверки плейлистов type CheckOptions struct { Repeat uint Random uint Files []string Urls []string Codes []string } // StartBackgroundChecker запускает фоновую периодическую проверку плейлистов. // Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine. func (s *Server) StartBackgroundChecker(opts CheckOptions) { // каждый проверенный плейлист сразу попадает в in-memory кеш checker.OnPlaylistChecked = func(pls playlist.Playlist) { s.memMu.Lock() s.memCache[pls.Code] = pls s.memMu.Unlock() // сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные s.iniLoaded = time.Time{} } // AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond cooldown := time.Duration(s.cfg.Check.Playlists.AllCooldown.Value()) * time.Millisecond log.Printf("Background checker started: cooldown=%s repeat=%d", cooldown, opts.Repeat) iteration := uint(0) for { iteration++ log.Printf("@ Background checker iteration=%d repeat=%d", iteration, opts.Repeat) s.runCheckerOnce(opts) if opts.Repeat > 0 && iteration >= opts.Repeat { log.Println("Background checker finished: reached repeat count") return } log.Printf("Background checker waiting... cooldown=%s", cooldown) // cooldown уже вычислен в миллисекундах выше time.Sleep(cooldown) } } // runCheckerOnce выполняет один цикл проверки плейлистов func (s *Server) runCheckerOnce(opts CheckOptions) { defer func() { if err := recover(); err != nil { log.Printf("Background checker panic: %v", err) } }() var lists []playlist.Playlist if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 { // проверка конкретных файлов/URL/кодов lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes) } else { // проверка всех плейлистов из ini ini, err := inifile.Init(s.cfg.App.Playlists) if err != nil { log.Printf("Background checker: cannot load ini: %s", err) return } lists = slices.Collect(maps.Values(ini.Lists)) } if len(lists) == 0 { log.Println("Background checker: no playlists to check") return } log.Printf("Background checker: %d playlists will be checked", len(lists)) startTime := time.Now() onlineCount, offlineCount := checker.CheckPlaylists(lists) log.Printf( "Background checker done! online=%d offline=%d elapsed=%.2fs", onlineCount, offlineCount, time.Since(startTime).Seconds(), ) }