212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
/*
|
|
* 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)
|
|
|
|
// 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 ""
|
|
}
|
|
return info.ModTime().Format("02.01.2006 15:04")
|
|
}
|
|
|
|
// 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(),
|
|
)
|
|
}
|