This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit 1fca796f2c
56 changed files with 9709 additions and 406 deletions
+59 -13
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -14,18 +14,57 @@ import (
"github.com/redis/go-redis/v9"
)
const VERSION = "1.1.3"
// Version информация, заполняется при сборке через ldflags
var (
VERSION = "dev"
COMMIT = "unknown"
)
// Arguments описывает аргументы командной строки
type Arguments struct {
IniPath string
TagsPath string
RandomCount uint
RepeatCount uint
RepeatEverySec uint
NeedJson bool
NeedQuiet bool
Verbose bool
IniPath string
TagsPath string
RandomCount uint
RepeatCount uint
NeedJson bool
NeedQuiet bool
Verbose bool
ServerPort uint
ServerHost string
ConfigPath string
NeedCheck bool
Files []string
Urls []string
Codes []string
// app
Debug bool
LogLevel string
// check.playlists
PlTimeout int
PlAllCooldown int
PlOneCooldown int
PlMaxRoutines int
PlPerRoutine int
PlUserAgent []string
// check.channels
ChTimeout int
ChByteRange int
ChCooldown int
ChMaxRoutines int
ChPerRoutine int
ChUserAgent []string
// cache
CacheEnabled bool
CacheHost string
CachePort uint
CacheUsername string
CachePassword string
CacheDb uint
CacheTtl uint
}
var (
@@ -34,11 +73,18 @@ var (
Config *config.Config
)
// Init инициализирует конфигурацию и подключение к keydb
// Init загружает конфигурацию и инициализирует логгер.
// CLI-override применяются через applyAppOverrides(), applyCacheOverrides()
// и applyCheckOverrides() в обработчиках команд после Init().
func Init() {
Config = config.Init()
Config = config.Init(Args.ConfigPath)
logger.Init(Args.NeedQuiet)
if Config.Cache.IsEnabled {
}
// InitCache инициализирует подключение к KeyDB/Redis, если кеш включён.
// Вызывайте после применения всех cache-override.
func InitCache() {
if Config.Cache.Enabled {
Cache = cache.Init(&Config.Cache)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
+115 -87
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -15,13 +15,12 @@ import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"maps"
"math"
"math/rand"
"net/http"
"os"
"slices"
"strings"
"sync"
@@ -29,8 +28,9 @@ import (
)
var (
tagBlocks []tagfile.TagBlock
ctx = context.Background()
tagBlocks []tagfile.TagBlock
ctx = context.Background()
OnPlaylistChecked func(playlist.Playlist) // вызывается после проверки каждого плейлиста
)
// PrepareListsToCheck готовит список плейлистов для проверки
@@ -57,7 +57,7 @@ func PrepareListsToCheck(files []string, urls []string, codes []string) []playli
}
if len(lists) == 0 || len(codes) > 0 {
ini, err := inifile.Init(app.Args.IniPath)
ini, err := inifile.Init(app.Config.App.Playlists)
if err != nil {
log.Printf("Warning: %s, all --code flags will be ignored\n", err)
return lists
@@ -109,56 +109,100 @@ func getCachedPlaylists() map[string]playlist.Playlist {
return result
}
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки
// CheckPlaylists проверяет плейлисты и возвращает их же с результатами проверки.
// Параметры (user-agent, timeout, cooldown, per-routine) берутся из config.Check.Playlists.
func CheckPlaylists(lists []playlist.Playlist) (int, int) {
count := len(lists)
if count == 0 {
log.Println("There are no playlists to check")
os.Exit(0)
return 0, 0
}
log.Printf("%d playlists will be checked\n", len(lists))
step, onlineCount, offlineCount := 0, 0, 0
tagBlocks = tagfile.Init(app.Args.TagsPath)
pc := app.Config.Check.Playlists
maxRoutines := pc.MaxRoutines
if maxRoutines < 1 {
maxRoutines = 1
}
log.Printf("%d playlists will be checked (max-routines=%d)\n", count, maxRoutines)
tagBlocks = tagfile.Init(app.Config.App.Tags)
type checkResult struct {
idx int
pls playlist.Playlist
online bool
}
sem := make(chan struct{}, maxRoutines)
var wg sync.WaitGroup
results := make([]checkResult, count)
for idx := range lists {
pls := lists[idx]
step++
wg.Add(1)
go func(idx int) {
sem <- struct{}{}
defer func() {
<-sem
wg.Done()
}()
var err error
if pls.Source == "-f" {
// direct m3u path
log.Printf("[%.3d/%.3d] Playlist from filesystem\n", step, count)
log.Printf("Reading file... (%s)\n", pls.Url)
err = pls.ReadFromFs()
} else if pls.Source == "-u" {
// direct m3u url
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Url)
log.Printf("Fetching... (%s)\n", pls.Url)
err = pls.Download()
} else {
// from ini
log.Printf("[%.3d/%.3d] Playlist [%s]\n", step, count, pls.Code)
log.Printf("Fetching... (%s)\n", pls.Url)
err = pls.Download()
}
pls := lists[idx]
pls.CheckedAt = time.Now().Unix()
if err != nil {
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
offlineCount++
userAgent := pc.UserAgent.Pick()
timeout := time.Duration(pc.Timeout) * time.Millisecond
var err error
if pls.Source == "-f" {
log.Printf("Playlist from filesystem (%s)\n", pls.Url)
err = pls.ReadFromFs()
} else {
log.Printf("Fetching playlist [%s] (%s)\n", pls.Code, pls.Url)
err = pls.Download(userAgent, timeout)
}
if err != nil {
log.Printf("Cannot read playlist [%s]: %s\n", pls.Url, err)
pls.IsOnline = false
results[idx] = checkResult{idx: idx, pls: pls, online: false}
cachePlaylist(pls)
if OnPlaylistChecked != nil {
OnPlaylistChecked(pls)
}
return
}
log.Println("Parsing content...")
pls.IsOnline = true
pls = pls.Parse()
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
pls = CheckChannels(pls)
results[idx] = checkResult{idx: idx, pls: pls, online: true}
cachePlaylist(pls)
continue
if OnPlaylistChecked != nil {
OnPlaylistChecked(pls)
}
// one-cooldown: задержка после проверки каждого плейлиста
// pc.OneCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
oneCd := time.Duration(pc.OneCooldown.Value()) * time.Millisecond
if oneCd > 0 {
time.Sleep(oneCd)
}
}(idx)
}
wg.Wait()
onlineCount, offlineCount := 0, 0
for _, r := range results {
lists[r.idx] = r.pls
if r.online {
onlineCount++
} else {
offlineCount++
}
log.Println("Parsing content...")
pls.IsOnline = true
onlineCount++
pls = pls.Parse()
log.Printf("Parsed, checking channels (%d)...\n", len(pls.Channels))
pls = CheckChannels(pls)
lists[idx] = pls
cachePlaylist(pls)
}
return onlineCount, offlineCount
@@ -183,7 +227,8 @@ func cachePlaylist(pls playlist.Playlist) {
log.Println("Cached sucessfully")
}
// CheckChannels проверяет каналы и возвращает их же с результатами проверки
// CheckChannels проверяет каналы и возвращает их же с результатами проверки.
// Параметры (user-agent, timeout, byte-range, cooldown, per-routine) берутся из config.Check.Channels.
func CheckChannels(pls playlist.Playlist) playlist.Playlist {
type errorData struct {
tvChannel playlist.Channel
@@ -199,14 +244,27 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
pls.OnlineCount = 0
pls.OfflineCount = 0
timeout, routines := calcParameters(count)
cc := app.Config.Check.Channels
timeoutMs := cc.Timeout
byteRange := cc.ByteRange
if byteRange <= 0 {
byteRange = 512
}
maxRoutines := cc.MaxRoutines
if maxRoutines < 1 {
maxRoutines = 1
}
timeout := time.Duration(timeoutMs) * time.Millisecond
httpClient := http.Client{Timeout: timeout}
chSemaphores := make(chan struct{}, routines)
chSemaphores := make(chan struct{}, maxRoutines)
chOnline := make(chan playlist.Channel, len(pls.Channels))
chOffline := make(chan playlist.Channel, len(pls.Channels))
chError := make(chan errorData, len(pls.Channels))
var wg sync.WaitGroup
log.Printf("Check channels parameters: timeout=%.2fs byte-range=%d max-routines=%d\n", float64(timeoutMs)/1000, byteRange, maxRoutines)
startTime := time.Now()
for _, tvChannel := range pls.Channels {
wg.Add(1)
@@ -227,8 +285,8 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
req.Header.Set("Range", "bytes=0-511") // 512 B, but sometimes servers ignore it
req.Header.Set("User-Agent", cc.UserAgent.Pick())
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", byteRange-1))
resp, err := httpClient.Do(req)
tvChannel.CheckedAt = time.Now().Unix()
if err != nil {
@@ -240,7 +298,7 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
tvChannel.Status = resp.StatusCode
tvChannel.IsOnline = tvChannel.Status < http.StatusBadRequest
tvChannel.ContentType = resp.Header.Get("Content-Type")
chunk := io.LimitReader(resp.Body, 512) // just for sure
chunk := io.LimitReader(resp.Body, int64(byteRange))
bodyBytes, _ := io.ReadAll(chunk)
bodyString := string(bodyBytes)
_ = resp.Body.Close()
@@ -263,7 +321,13 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
}
chOnline <- tvChannel
return
// cooldown: задержка после проверки каждого канала
// cc.Cooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
cd := time.Duration(cc.Cooldown.Value()) * time.Millisecond
if cd > 0 {
time.Sleep(cd)
}
}(tvChannel)
}
@@ -323,42 +387,6 @@ func CheckChannels(pls playlist.Playlist) playlist.Playlist {
return pls
}
// calcParameters вычисляет оптимальное количество горутин и таймаут запроса
func calcParameters(count int) (time.Duration, int) {
routines := count
if routines > 3000 {
routines = 3000
}
if routines < 1 {
routines = 1
}
var digits = 1
x := count
for x >= 10 {
digits++
x /= 10
}
timeout := 10 - int(math.Ceil(float64(digits)*1.5))
if timeout > 10 {
timeout = 10
}
if timeout < 1 {
timeout = 1
}
duration := time.Duration(timeout) * time.Second
log.Printf(
"Check parameters calculated count=%d timeout=%.2fs routines=%d\n",
count,
duration.Seconds(),
routines,
)
return duration, routines
}
// getTagsForChannel ищет и возвращает теги для канала
func getTagsForChannel(tvChannel playlist.Channel) []string {
var foundTags []string
+591 -39
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -7,71 +7,623 @@
package config
import (
"github.com/joho/godotenv"
"fmt"
"log"
"math/rand"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
"gopkg.in/yaml.v3"
)
// Константы значений по умолчанию для временных параметров.
// Внешний интерфейс (YAML, env, CLI) задаёт их в секундах,
// внутреннее представление — в миллисекундах.
const (
defaultTimeoutSeconds = 10
defaultAllCooldownSeconds = 1800 // 30 минут
defaultOneCooldownSeconds = 2
defaultChannelsCooldownSeconds = 0
defaultCacheTtlSeconds = 30
)
// Config описывает конфигурацию
type Config struct {
DebugMode bool
Cache CacheConfig
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Site SiteConfig `yaml:"site"`
Check CheckConfig `yaml:"check"`
Cache CacheConfig `yaml:"cache"`
}
// CacheConfig описывает конфигурацию подключения к keydb
// AppConfig описывает общие настройки приложения
type AppConfig struct {
Timezone string `yaml:"timezone"`
Debug bool `yaml:"debug"`
LogLevel string `yaml:"log_level"`
Playlists string `yaml:"playlists"`
Tags string `yaml:"tags"`
}
// ServerConfig описывает конфигурацию веб-сервера
type ServerConfig struct {
Port uint `yaml:"port"`
Host string `yaml:"host"`
}
// Link описывает ссылку в навигации или подвале сайта.
// Если заданы Children, рендерится как выпадающее меню.
type Link struct {
Title string `yaml:"title"`
Url string `yaml:"url"`
Icon string `yaml:"icon"`
Children []Link `yaml:"children"`
}
// HeaderConfig описывает настройки шапки сайта
type HeaderConfig struct {
Title string `yaml:"title"`
Navigation []Link `yaml:"navigation"`
}
// SiteConfig описывает настройки сайта
type SiteConfig struct {
BaseUrl string `yaml:"base-url"`
RepoUrl string `yaml:"repo-url"`
PageSize uint `yaml:"page-size"`
Favicon string `yaml:"favicon"`
Header HeaderConfig `yaml:"header"`
FooterLinks []Link `yaml:"footer-links"`
}
// CacheConfig описывает конфигурацию подключения к keydb.
// Ttl задаётся в секундах.
type CacheConfig struct {
IsEnabled bool
Host string
Port uint
Username string
Password string
Db uint
Ttl uint
IsActive bool
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port uint `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Db uint `yaml:"db"`
Ttl uint `yaml:"ttl"`
IsActive bool `yaml:"-"`
}
// Init инициализирует объект конфигурации из переменных окружения
func Init() *Config {
_ = godotenv.Load(".env")
// CheckConfig описывает параметры проверки плейлистов и каналов
type CheckConfig struct {
StartOnServe bool `yaml:"start-on-serve"`
Playlists CheckPlaylistsConfig `yaml:"playlists"`
Channels CheckChannelsConfig `yaml:"channels"`
}
// DurationSeconds — тип для временных интервалов, задаваемых в секундах,
// но хранящихся внутри в миллисекундах.
type DurationSeconds int
// UnmarshalYAML декодирует значение из секунд в миллисекунды.
func (d *DurationSeconds) UnmarshalYAML(value *yaml.Node) error {
var v float64
if err := value.Decode(&v); err != nil {
return err
}
*d = DurationSeconds(v * 1000)
return nil
}
// CheckPlaylistsConfig — параметры проверки плейлистов.
// Значения Timeout, AllCooldown, OneCooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckPlaylistsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
AllCooldown IntRange `yaml:"all-cooldown"` // ms internally
OneCooldown IntRange `yaml:"one-cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// CheckChannelsConfig — параметры проверки каналов.
// Значения Timeout и Cooldown задаются в секундах
// в YAML/CLI/env, но хранятся в миллисекундах для использования в коде.
type CheckChannelsConfig struct {
UserAgent UserAgents `yaml:"user-agent"`
Timeout DurationSeconds `yaml:"timeout"` // ms internally
ByteRange int `yaml:"byte-range"` // bytes
Cooldown IntRange `yaml:"cooldown"` // ms internally
MaxRoutines int `yaml:"max-routines"` // count
PerRoutine int `yaml:"per-routine"` // count
}
// IntRange — значение, которое может быть скаляром или [min, max].
// Если скаляр: Min == Max. Если [min, max]: Min <= Max.
type IntRange struct {
Min int
Max int
}
// UnmarshalYAML реализует кастомный разбор IntRange из YAML.
// В YAML значение задаётся в секундах, результат сохраняется в миллисекундах.
// Поддерживает скаляр (int/float) или массив из двух значений [min, max].
func (r *IntRange) UnmarshalYAML(value *yaml.Node) error {
decodeNode := func(n *yaml.Node) (int, error) {
var f float64
if err := n.Decode(&f); err != nil {
return 0, err
}
return int(f * 1000), nil
}
if value.Kind == yaml.ScalarNode {
v, err := decodeNode(value)
if err != nil {
return err
}
r.Min = v
r.Max = v
return nil
}
if value.Kind == yaml.SequenceNode {
if len(value.Content) == 0 {
r.Min, r.Max = 0, 0
return nil
}
min, err := decodeNode(value.Content[0])
if err != nil {
return err
}
r.Min = min
if len(value.Content) >= 2 {
max, err := decodeNode(value.Content[1])
if err != nil {
return err
}
r.Max = max
} else {
r.Max = min
}
return nil
}
return nil
}
// Value возвращает значение: если Min == Max — константу, иначе случайное в [Min, Max].
func (r IntRange) Value() int {
if r.Min == r.Max {
return r.Min
}
return r.Min + rand.Intn(r.Max-r.Min+1)
}
// UserAgents — список User-Agent строк. Может быть задан как скаляр или массив.
type UserAgents []string
// UnmarshalYAML реализует кастомный разбор UserAgents из YAML.
func (u *UserAgents) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var s string
if err := value.Decode(&s); err != nil {
return err
}
*u = []string{s}
return nil
}
if value.Kind == yaml.SequenceNode {
var arr []string
if err := value.Decode(&arr); err != nil {
return err
}
*u = arr
return nil
}
return nil
}
// Pick возвращает случайный User-Agent или пустую строку если список пуст.
func (u UserAgents) Pick() string {
if len(u) == 0 {
return ""
}
if len(u) == 1 {
return u[0]
}
return u[rand.Intn(len(u))]
}
// defaults возвращает конфигурацию со значениями по умолчанию.
// Временные параметры хранятся в миллисекундах.
func defaults() *Config {
return &Config{
//DebugMode: readEnvBoolean("APP_DEBUG", false),
App: AppConfig{
Timezone: "GMT",
Debug: false,
LogLevel: "info",
Playlists: "./playlists.ini",
Tags: "./channels.json",
},
Server: ServerConfig{
Port: 8800,
Host: "",
},
Site: SiteConfig{
BaseUrl: "http://localhost:8800",
PageSize: 0,
RepoUrl: "https://git.axenov.dev/IPTV",
Header: HeaderConfig{
Title: "IPTV Checker",
Navigation: []Link{
{Title: "Помощь", Icon: "help-circle-outline", Children: []Link{
{Title: "Документация", Url: "https://m3u.su/docs", Icon: "document-text-outline"},
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "paper-plane-outline"},
}},
},
},
FooterLinks: []Link{
{Title: "Исходники", Url: "https://git.axenov.dev/IPTV", Icon: "code-slash-outline"},
{Title: "axenov.dev", Url: "https://axenov.dev", Icon: "person-outline"},
{Title: "@iptv_aggregator", Url: "https://t.me/iptv_aggregator", Icon: "megaphone-outline"},
},
},
Check: CheckConfig{
StartOnServe: false,
Playlists: CheckPlaylistsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
AllCooldown: IntRange{Min: defaultAllCooldownSeconds * 1000, Max: defaultAllCooldownSeconds * 1000},
OneCooldown: IntRange{Min: defaultOneCooldownSeconds * 1000, Max: defaultOneCooldownSeconds * 1000},
MaxRoutines: 1,
PerRoutine: 1,
},
Channels: CheckChannelsConfig{
UserAgent: UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"},
Timeout: defaultTimeoutSeconds * 1000,
ByteRange: 512,
Cooldown: IntRange{Min: defaultChannelsCooldownSeconds * 1000, Max: defaultChannelsCooldownSeconds * 1000},
MaxRoutines: 50,
PerRoutine: 10,
},
},
Cache: CacheConfig{
IsEnabled: readEnvBoolean("CACHE_ENABLED", false),
Host: readEnv("CACHE_HOST", "localhost"),
Port: readEnvInteger("CACHE_PORT", 6379),
Username: readEnv("CACHE_USERNAME", ""),
Password: readEnv("CACHE_PASSWORD", ""),
Db: readEnvInteger("CACHE_DB", 0),
Ttl: readEnvInteger("CACHE_TTL", 1800),
Enabled: false,
Host: "localhost",
Port: 6379,
Username: "",
Password: "",
Db: 0,
Ttl: defaultCacheTtlSeconds,
},
}
}
// readEnv считывает строковую переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnv(key string, defaultValue string) string {
value, exists := os.LookupEnv(key)
if exists {
return value
// Init инициализирует объект конфигурации.
// Порядок приоритета (от низшего к высшему):
// 1. Значения по умолчанию
// 2. config.yml (путь можно задать через configPath)
// 3. Переменные окружения (.env загружается автоматически)
func Init(configPath string) *Config {
_ = godotenv.Load(".env")
cfg := defaults()
// загружаем config.yml
if configPath == "" {
configPath = "config.yml"
}
return defaultValue
if data, err := os.ReadFile(configPath); err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("Warning: cannot parse %s: %s", configPath, err)
}
} else if !os.IsNotExist(err) {
log.Printf("Warning: cannot read %s: %s", configPath, err)
}
// переопределяем значениями из переменных окружения
applyEnv(cfg)
// валидируем и исправляем
validate(cfg)
return cfg
}
// readEnvBoolean считывает булеву переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvBoolean(name string, defaultValue bool) bool {
valStr := readEnv(name, "")
val, err := strconv.ParseBool(valStr)
// applyEnv переопределяет поля конфигурации значениями из переменных окружения.
func applyEnv(cfg *Config) {
// app
if v, ok := os.LookupEnv("APP_DEBUG"); ok {
cfg.App.Debug = parseBool(v, cfg.App.Debug)
}
if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok && v != "" {
cfg.App.LogLevel = v
}
if v, ok := os.LookupEnv("APP_TIMEZONE"); ok && v != "" {
cfg.App.Timezone = v
}
if v, ok := os.LookupEnv("APP_PLAYLISTS"); ok && v != "" {
cfg.App.Playlists = v
}
if v, ok := os.LookupEnv("APP_TAGS"); ok && v != "" {
cfg.App.Tags = v
}
// server
if v, ok := os.LookupEnv("SERVER_PORT"); ok && v != "" {
cfg.Server.Port = parseUint(v, cfg.Server.Port)
}
if v, ok := os.LookupEnv("SERVER_HOST"); ok {
cfg.Server.Host = v
}
// site
if v, ok := os.LookupEnv("APP_URL"); ok && v != "" {
cfg.Site.BaseUrl = v
}
if v, ok := os.LookupEnv("PAGE_SIZE"); ok && v != "" {
cfg.Site.PageSize = parseUint(v, cfg.Site.PageSize)
}
if v, ok := os.LookupEnv("REPO_URL"); ok && v != "" {
cfg.Site.RepoUrl = v
}
if v, ok := os.LookupEnv("SITE_FAVICON"); ok {
cfg.Site.Favicon = v
}
if v, ok := os.LookupEnv("APP_TITLE"); ok && v != "" {
cfg.Site.Header.Title = v
}
// check
if v, ok := os.LookupEnv("CHECK_START_ON_SERVE"); ok {
cfg.Check.StartOnServe = parseBool(v, cfg.Check.StartOnServe)
}
// check.playlists
pp := &cfg.Check.Playlists
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_TIMEOUT"); ok && v != "" {
pp.Timeout = DurationSeconds(parseSecondsToMillis(v, int(pp.Timeout)))
}
applyEnvIntRange(&pp.AllCooldown, "CHECK_PLAYLISTS_ALL_COOLDOWN")
applyEnvIntRange(&pp.OneCooldown, "CHECK_PLAYLISTS_ONE_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_MAX_ROUTINES"); ok && v != "" {
pp.MaxRoutines = parseInt(v, pp.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_PLAYLISTS_PER_ROUTINE"); ok && v != "" {
pp.PerRoutine = parseInt(v, pp.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_PLAYLISTS_USER_AGENT"); uas != nil {
pp.UserAgent = UserAgents(uas)
}
// check.channels
cc := &cfg.Check.Channels
if v, ok := os.LookupEnv("CHECK_CHANNELS_TIMEOUT"); ok && v != "" {
cc.Timeout = DurationSeconds(parseSecondsToMillis(v, int(cc.Timeout)))
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_BYTE_RANGE"); ok && v != "" {
cc.ByteRange = parseInt(v, cc.ByteRange)
}
applyEnvIntRange(&cc.Cooldown, "CHECK_CHANNELS_COOLDOWN")
if v, ok := os.LookupEnv("CHECK_CHANNELS_MAX_ROUTINES"); ok && v != "" {
cc.MaxRoutines = parseInt(v, cc.MaxRoutines)
}
if v, ok := os.LookupEnv("CHECK_CHANNELS_PER_ROUTINE"); ok && v != "" {
cc.PerRoutine = parseInt(v, cc.PerRoutine)
}
if uas := applyEnvStringSlice("CHECK_CHANNELS_USER_AGENT"); uas != nil {
cc.UserAgent = UserAgents(uas)
}
// cache
if v, ok := os.LookupEnv("CACHE_ENABLED"); ok {
cfg.Cache.Enabled = parseBool(v, cfg.Cache.Enabled)
}
if v, ok := os.LookupEnv("CACHE_HOST"); ok && v != "" {
cfg.Cache.Host = v
}
if v, ok := os.LookupEnv("CACHE_PORT"); ok && v != "" {
cfg.Cache.Port = parseUint(v, cfg.Cache.Port)
}
if v, ok := os.LookupEnv("CACHE_USERNAME"); ok {
cfg.Cache.Username = v
}
if v, ok := os.LookupEnv("CACHE_PASSWORD"); ok {
cfg.Cache.Password = v
}
if v, ok := os.LookupEnv("CACHE_DB"); ok && v != "" {
cfg.Cache.Db = parseUint(v, cfg.Cache.Db)
}
if v, ok := os.LookupEnv("CACHE_TTL"); ok && v != "" {
cfg.Cache.Ttl = parseUint(v, cfg.Cache.Ttl)
}
}
// parseBool разбирает строку в bool, при ошибке возвращает значение по умолчанию
func parseBool(s string, defaultValue bool) bool {
val, err := strconv.ParseBool(s)
if err == nil {
return val
}
return defaultValue
}
// readEnvInteger считывает целочисленную переменную окружения с заданным именем или возвращает значение по умолчанию
func readEnvInteger(name string, defaultValue uint) uint {
valueStr := readEnv(name, "")
value, err := strconv.Atoi(valueStr)
// parseUint разбирает строку в uint, при ошибке возвращает значение по умолчанию
func parseUint(s string, defaultValue uint) uint {
val, err := strconv.Atoi(s)
if err == nil {
return uint(value)
return uint(val)
}
return defaultValue
}
// parseInt разбирает строку в int, при ошибке возвращает значение по умолчанию
func parseInt(s string, defaultValue int) int {
val, err := strconv.Atoi(s)
if err == nil {
return val
}
return defaultValue
}
// parseSecondsToMillis разбирает строку с секундами и возвращает миллисекунды.
// При ошибке возвращает значение по умолчанию.
func parseSecondsToMillis(s string, defaultValue int) int {
val, err := strconv.ParseFloat(s, 64)
if err == nil && val >= 0 {
return int(val * 1000)
}
return defaultValue
}
// applyEnvIntRange читает одно env-значение (в секундах) для IntRange,
// умножая результат на 1000, так как внутри значения хранятся в миллисекундах.
// Одно число задаёт фиксированное значение, два числа через запятую — диапазон.
func applyEnvIntRange(r *IntRange, name string) {
value, ok := os.LookupEnv(name)
if !ok || strings.TrimSpace(value) == "" {
return
}
parse := func(s string) (int, bool) {
s = strings.TrimSpace(s)
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return 0, false
}
return int(v * 1000), true
}
parts := strings.Split(value, ",")
if len(parts) == 1 {
n, ok := parse(parts[0])
if !ok {
return
}
r.Min = n
r.Max = n
return
}
if len(parts) != 2 {
return
}
min, minOk := parse(parts[0])
max, maxOk := parse(parts[1])
if !minOk || !maxOk {
return
}
r.Min = min
r.Max = max
}
// applyEnvStringSlice читает индексированные env-переменные PREFIX_1, PREFIX_2, ...
// Возвращает nil, если ни одна не задана.
func applyEnvStringSlice(prefix string) []string {
var result []string
for i := 1; ; i++ {
key := fmt.Sprintf("%s_%d", prefix, i)
v, ok := os.LookupEnv(key)
if !ok {
break
}
if v != "" {
result = append(result, v)
}
}
return result
}
// validate проверяет значения конфигурации и исправляет некорректные,
// логируя предупреждения для каждого исправления.
func validate(cfg *Config) {
// server
if cfg.Server.Port == 0 || cfg.Server.Port > 65535 {
log.Println("Config: server.port is not in the 0..65535 range, using default 8800")
cfg.Server.Port = 8800
}
if cfg.Site.BaseUrl == "" {
log.Println("Config: site.base-url is empty, using default")
cfg.Site.BaseUrl = fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
}
// cache
if cfg.Cache.Enabled {
if cfg.Cache.Host == "" {
log.Println("Config: cache.host is empty, using default 'localhost'")
cfg.Cache.Host = "localhost"
}
if cfg.Cache.Port == 0 {
log.Println("Config: cache.port is 0, using default 6379")
cfg.Cache.Port = 6379
}
if cfg.Cache.Ttl == 0 {
log.Printf("Config: cache.ttl is 0, using default %d s", defaultCacheTtlSeconds)
cfg.Cache.Ttl = defaultCacheTtlSeconds
}
}
// check.playlists
pp := &cfg.Check.Playlists
if pp.Timeout <= 0 {
log.Printf("Config: check.playlists.timeout <= 0, using default %d s", defaultTimeoutSeconds)
pp.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&pp.AllCooldown, "check.playlists.all-cooldown", 0, 3600000)
validateIntRange(&pp.OneCooldown, "check.playlists.one-cooldown", 0, 3600000)
if pp.MaxRoutines < 1 {
log.Println("Config: check.playlists.max-routines < 1, using default 5")
pp.MaxRoutines = 5
}
if pp.PerRoutine < 1 {
log.Println("Config: check.playlists.per-routine < 1, using default 1")
pp.PerRoutine = 1
}
if len(pp.UserAgent) == 0 {
log.Println("Config: check.playlists.user-agent is empty, using default")
pp.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
// check.channels
cc := &cfg.Check.Channels
if cc.Timeout <= 0 {
log.Printf("Config: check.channels.timeout <= 0, using default %d s", defaultTimeoutSeconds)
cc.Timeout = defaultTimeoutSeconds * 1000
}
validateIntRange(&cc.Cooldown, "check.channels.cooldown", 0, 3600000)
if cc.MaxRoutines < 1 {
log.Println("Config: check.channels.max-routines < 1, using default 50")
cc.MaxRoutines = 50
}
if cc.PerRoutine < 1 {
log.Println("Config: check.channels.per-routine < 1, using default 10")
cc.PerRoutine = 10
}
if cc.ByteRange <= 0 {
log.Println("Config: check.channels.byte-range <= 0, using default 512")
cc.ByteRange = 512
}
if len(cc.UserAgent) == 0 {
log.Println("Config: check.channels.user-agent is empty, using default")
cc.UserAgent = UserAgents{"Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer"}
}
}
// validateIntRange проверяет IntRange: Min <= Max, оба в пределах [minVal, maxVal].
// Некорректные значения исправляются с логированием предупреждения.
func validateIntRange(r *IntRange, name string, minVal, maxVal int) {
if r.Min > r.Max {
log.Printf("Config: %s min=%d > max=%d, swapping", name, r.Min, r.Max)
r.Min, r.Max = r.Max, r.Min
}
if r.Min < minVal {
log.Printf("Config: %s min=%d < %d, clamped", name, r.Min, minVal)
r.Min = minVal
}
if r.Max > maxVal {
log.Printf("Config: %s max=%d > %d, clamped", name, r.Max, maxVal)
r.Max = maxVal
}
if r.Min > r.Max {
r.Min = minVal
r.Max = minVal
}
}
+548
View File
@@ -0,0 +1,548 @@
package config
import (
"os"
"testing"
"gopkg.in/yaml.v3"
)
// --- IntRange ---
func TestIntRangeValue_Scalar(t *testing.T) {
r := IntRange{Min: 5, Max: 5}
if v := r.Value(); v != 5 {
t.Errorf("expected 5, got %d", v)
}
}
func TestIntRangeValue_Range(t *testing.T) {
r := IntRange{Min: 10, Max: 20}
for i := 0; i < 100; i++ {
v := r.Value()
if v < 10 || v > 20 {
t.Errorf("value %d out of [10, 20]", v)
}
}
}
func TestIntRangeUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte("timeout: 5000\n")
type wrapper struct {
Timeout int `yaml:"timeout"`
}
var w wrapper
if err := yaml.Unmarshal(yamlData, &w); err != nil {
t.Fatal(err)
}
if w.Timeout != 5000 {
t.Errorf("expected 5000, got %d", w.Timeout)
}
}
func TestIntRangeUnmarshalYAML_FromFullConfig(t *testing.T) {
yamlData := []byte(`
check:
playlists:
all-cooldown: 1
one-cooldown: [2, 3]
channels:
cooldown: [0, 1]
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 1000 {
t.Errorf("scalar all-cooldown: expected Min=Max=1000 ms from 1 s, got Min=%d Max=%d",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Playlists.OneCooldown.Min != 2000 || cfg.Check.Playlists.OneCooldown.Max != 3000 {
t.Errorf("range one-cooldown: expected [2000,3000] ms from [2,3] s, got [%d,%d]",
cfg.Check.Playlists.OneCooldown.Min, cfg.Check.Playlists.OneCooldown.Max)
}
if cfg.Check.Channels.Cooldown.Min != 0 || cfg.Check.Channels.Cooldown.Max != 1000 {
t.Errorf("range cooldown: expected [0,1000] ms from [0,1] s, got [%d,%d]",
cfg.Check.Channels.Cooldown.Min, cfg.Check.Channels.Cooldown.Max)
}
}
// --- UserAgents ---
func TestUserAgentsPick_Empty(t *testing.T) {
ua := UserAgents{}
if ua.Pick() != "" {
t.Error("expected empty string for empty UserAgents")
}
}
func TestUserAgentsPick_Single(t *testing.T) {
ua := UserAgents{"Mozilla"}
if ua.Pick() != "Mozilla" {
t.Errorf("expected 'Mozilla', got '%s'", ua.Pick())
}
}
func TestUserAgentsPick_Multiple(t *testing.T) {
ua := UserAgents{"A", "B", "C"}
for i := 0; i < 100; i++ {
v := ua.Pick()
found := false
for _, s := range ua {
if v == s {
found = true
break
}
}
if !found {
t.Errorf("Pick returned '%s' which is not in list", v)
}
}
}
func TestUserAgentsUnmarshalYAML_Scalar(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent: Mozilla/5.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 1 || cfg.Check.Channels.UserAgent[0] != "Mozilla/5.0" {
t.Errorf("expected single 'Mozilla/5.0', got %v", cfg.Check.Channels.UserAgent)
}
}
func TestUserAgentsUnmarshalYAML_Array(t *testing.T) {
yamlData := []byte(`
check:
channels:
user-agent:
- Mozilla/5.0
- curl/8.0
`)
cfg := defaults()
if err := yaml.Unmarshal(yamlData, cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Check.Channels.UserAgent) != 2 {
t.Errorf("expected 2 user-agents, got %d", len(cfg.Check.Channels.UserAgent))
}
}
// --- parseBool / parseUint / parseInt ---
func TestParseBool(t *testing.T) {
cases := []struct {
in string
def bool
want bool
}{
{"true", false, true},
{"1", false, true},
{"false", true, false},
{"0", true, false},
{"yes", false, false},
{"", true, true},
}
for _, c := range cases {
got := parseBool(c.in, c.def)
if got != c.want {
t.Errorf("parseBool(%q, %v) = %v, want %v", c.in, c.def, got, c.want)
}
}
}
func TestParseUint(t *testing.T) {
cases := []struct {
in string
def uint
want uint
}{
{"8800", 0, 8800},
{"0", 999, 0},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseUint(c.in, c.def)
if got != c.want {
t.Errorf("parseUint(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestParseInt(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"100", 0, 100},
{"-5", 0, -5},
{"abc", 999, 999},
{"", 999, 999},
}
for _, c := range cases {
got := parseInt(c.in, c.def)
if got != c.want {
t.Errorf("parseInt(%q, %d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
// --- applyEnvIntRange ---
func TestApplyEnvIntRange_Scalar(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1.5")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1500 || r.Max != 1500 {
t.Errorf("expected Min=Max=1500, got Min=%d Max=%d", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Range(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,2")
r := IntRange{Min: 0, Max: 0}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 1000 || r.Max != 2000 {
t.Errorf("expected [1000,2000], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_Invalid(t *testing.T) {
t.Setenv("TEST_COOLDOWN", "1,invalid")
r := IntRange{Min: 5, Max: 5}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 5 || r.Max != 5 {
t.Errorf("expected unchanged [5,5], got [%d,%d]", r.Min, r.Max)
}
}
func TestApplyEnvIntRange_None(t *testing.T) {
r := IntRange{Min: 7, Max: 7}
applyEnvIntRange(&r, "TEST_COOLDOWN")
if r.Min != 7 || r.Max != 7 {
t.Errorf("expected unchanged [7,7], got [%d,%d]", r.Min, r.Max)
}
}
// --- applyEnvStringSlice ---
func TestApplyEnvStringSlice_Multiple(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "bar")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
if result[0] != "foo" || result[1] != "bar" || result[2] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_EmptyValue(t *testing.T) {
t.Setenv("MY_PREFIX_1", "foo")
t.Setenv("MY_PREFIX_2", "")
t.Setenv("MY_PREFIX_3", "baz")
result := applyEnvStringSlice("MY_PREFIX")
if len(result) != 2 {
t.Fatalf("expected 2 items (empty skipped), got %d: %v", len(result), result)
}
if result[0] != "foo" || result[1] != "baz" {
t.Errorf("unexpected result: %v", result)
}
}
func TestApplyEnvStringSlice_None(t *testing.T) {
result := applyEnvStringSlice("NONEXISTENT_PREFIX")
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
// --- validate ---
func TestValidate_ServerPort(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 0
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_ServerPortTooHigh(t *testing.T) {
cfg := defaults()
cfg.Server.Port = 99999
validate(cfg)
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
}
func TestValidate_BaseUrlEmpty(t *testing.T) {
cfg := defaults()
cfg.Site.BaseUrl = ""
cfg.Server.Port = 3000
validate(cfg)
if cfg.Site.BaseUrl != "http://localhost:3000" {
t.Errorf("expected http://localhost:3000, got %s", cfg.Site.BaseUrl)
}
}
func TestValidate_CacheEnabled(t *testing.T) {
cfg := defaults()
cfg.Cache.Enabled = true
cfg.Cache.Host = ""
cfg.Cache.Port = 0
cfg.Cache.Ttl = 0
validate(cfg)
if cfg.Cache.Host != "localhost" {
t.Errorf("expected localhost, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
if cfg.Cache.Ttl != 30 {
t.Errorf("expected 30, got %d", cfg.Cache.Ttl)
}
}
func TestValidate_PlaylistsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.Timeout = 0
validate(cfg)
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Playlists.Timeout)
}
}
func TestValidate_ChannelsTimeout(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.Timeout = -5
validate(cfg)
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000, got %d", cfg.Check.Channels.Timeout)
}
}
func TestValidate_PlaylistsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.MaxRoutines = 0
validate(cfg)
if cfg.Check.Playlists.MaxRoutines != 5 {
t.Errorf("expected 5, got %d", cfg.Check.Playlists.MaxRoutines)
}
}
func TestValidate_ChannelsMaxRoutines(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.MaxRoutines = 0
validate(cfg)
if cfg.Check.Channels.MaxRoutines != 50 {
t.Errorf("expected 50, got %d", cfg.Check.Channels.MaxRoutines)
}
}
func TestValidate_ChannelsByteRange(t *testing.T) {
cfg := defaults()
cfg.Check.Channels.ByteRange = 0
validate(cfg)
if cfg.Check.Channels.ByteRange != 512 {
t.Errorf("expected 512, got %d", cfg.Check.Channels.ByteRange)
}
}
func TestValidate_UserAgentEmpty(t *testing.T) {
cfg := defaults()
cfg.Check.Playlists.UserAgent = nil
cfg.Check.Channels.UserAgent = nil
validate(cfg)
if len(cfg.Check.Playlists.UserAgent) == 0 {
t.Error("expected non-empty playlists user-agent")
}
if len(cfg.Check.Channels.UserAgent) == 0 {
t.Error("expected non-empty channels user-agent")
}
}
// --- validateIntRange ---
func TestValidateIntRange_Swap(t *testing.T) {
r := IntRange{Min: 100, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 10 || r.Max != 100 {
t.Errorf("expected [10,100] after swap, got [%d,%d]", r.Min, r.Max)
}
}
func TestValidateIntRange_ClampMin(t *testing.T) {
r := IntRange{Min: -5, Max: 10}
validateIntRange(&r, "test", 0, 1000)
if r.Min != 0 {
t.Errorf("expected Min=0 after clamp, got %d", r.Min)
}
}
func TestValidateIntRange_ClampMax(t *testing.T) {
r := IntRange{Min: 0, Max: 999999}
validateIntRange(&r, "test", 0, 1000)
if r.Max != 1000 {
t.Errorf("expected Max=1000 after clamp, got %d", r.Max)
}
}
func TestValidateIntRange_SwapThenClamp(t *testing.T) {
r := IntRange{Min: 2000, Max: -10}
validateIntRange(&r, "test", 0, 1000)
// swap → [10, 2000], clamp → [0, 1000]
if r.Min != 0 || r.Max != 1000 {
t.Errorf("expected [0,1000], got [%d,%d]", r.Min, r.Max)
}
}
// --- defaults ---
func TestDefaults(t *testing.T) {
cfg := defaults()
if cfg.App.Timezone != "GMT" {
t.Errorf("expected GMT, got %s", cfg.App.Timezone)
}
if cfg.Server.Port != 8800 {
t.Errorf("expected 8800, got %d", cfg.Server.Port)
}
if cfg.Check.Playlists.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Channels.Timeout != 10000 {
t.Errorf("expected 10000 ms, got %d", cfg.Check.Channels.Timeout)
}
if cfg.Cache.Port != 6379 {
t.Errorf("expected 6379, got %d", cfg.Cache.Port)
}
if len(cfg.Site.Header.Navigation) == 0 {
t.Error("expected non-empty navigation")
}
}
// --- Init (integration) ---
func TestInit_NoConfigFile(t *testing.T) {
cfg := Init("/nonexistent/config.yml")
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.Server.Port != 8800 {
t.Errorf("expected default port 8800, got %d", cfg.Server.Port)
}
}
func TestInit_WithConfigFile(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte(`
app:
timezone: UTC+3
debug: true
server:
port: 3000
host: 0.0.0.0
check:
playlists:
timeout: 5
all-cooldown: [1, 2]
channels:
byte-range: 1024
cache:
enabled: true
host: db.local
port: 6380
`)
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
cfg := Init(tmp)
if cfg.App.Timezone != "UTC+3" {
t.Errorf("expected UTC+3, got %s", cfg.App.Timezone)
}
if !cfg.App.Debug {
t.Error("expected debug=true")
}
if cfg.Server.Port != 3000 {
t.Errorf("expected 3000, got %d", cfg.Server.Port)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("expected 0.0.0.0, got %s", cfg.Server.Host)
}
if cfg.Check.Playlists.Timeout != 5000 {
t.Errorf("expected 5000 ms from 5 s, got %d", cfg.Check.Playlists.Timeout)
}
if cfg.Check.Playlists.AllCooldown.Min != 1000 || cfg.Check.Playlists.AllCooldown.Max != 2000 {
t.Errorf("expected [1000,2000] ms from [1,2] s, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
if cfg.Check.Channels.ByteRange != 1024 {
t.Errorf("expected 1024, got %d", cfg.Check.Channels.ByteRange)
}
if !cfg.Cache.Enabled {
t.Error("expected cache enabled")
}
if cfg.Cache.Host != "db.local" {
t.Errorf("expected db.local, got %s", cfg.Cache.Host)
}
if cfg.Cache.Port != 6380 {
t.Errorf("expected 6380, got %d", cfg.Cache.Port)
}
}
func TestInit_EnvOverridesConfig(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("server:\n port: 3000\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("SERVER_PORT", "9090")
t.Setenv("APP_DEBUG", "true")
t.Setenv("CHECK_PLAYLISTS_TIMEOUT", "7")
t.Setenv("CHECK_PLAYLISTS_USER_AGENT_1", "CustomUA")
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "1.5,2.5")
cfg := Init(tmp)
if cfg.Server.Port != 9090 {
t.Errorf("expected 9090 from env, got %d", cfg.Server.Port)
}
if !cfg.App.Debug {
t.Error("expected debug=true from env")
}
if cfg.Check.Playlists.Timeout != 7000 {
t.Errorf("expected 7000 ms from 7 s env, got %d", cfg.Check.Playlists.Timeout)
}
if len(cfg.Check.Playlists.UserAgent) != 1 || cfg.Check.Playlists.UserAgent[0] != "CustomUA" {
t.Errorf("expected [CustomUA], got %v", cfg.Check.Playlists.UserAgent)
}
if cfg.Check.Playlists.AllCooldown.Min != 1500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected [1500,2500] ms from [1.5,2.5] s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
func TestInit_EnvScalarCooldownOverridesRange(t *testing.T) {
tmp := t.TempDir() + "/config.yml"
data := []byte("check:\n playlists:\n all-cooldown: [1, 2]\n")
if err := os.WriteFile(tmp, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CHECK_PLAYLISTS_ALL_COOLDOWN", "2.5")
cfg := Init(tmp)
if cfg.Check.Playlists.AllCooldown.Min != 2500 || cfg.Check.Playlists.AllCooldown.Max != 2500 {
t.Errorf("expected scalar [2500,2500] ms from 2.5 s env, got [%d,%d]",
cfg.Check.Playlists.AllCooldown.Min, cfg.Check.Playlists.AllCooldown.Max)
}
}
// helper removed — using yaml.Unmarshal directly
+3 -2
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -9,10 +9,11 @@ package inifile
import (
"axenov/iptv-checker/app/playlist"
"axenov/iptv-checker/app/utils"
"gopkg.in/ini.v1"
"log"
"os"
"strings"
"gopkg.in/ini.v1"
)
// IniFile описывает ini-файл c плейлистами
+91
View File
@@ -0,0 +1,91 @@
package inifile
import (
"os"
"path/filepath"
"testing"
)
func TestInit_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[RU]
pls = http://example.com/ru.m3u
name = Russian Playlist
desc = Russian channels
src = public
[EN]
pls = http://example.com/en.m3u
name = English Playlist
`
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatal(err)
}
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
if len(ini.Lists) != 2 {
t.Fatalf("expected 2 playlists, got %d", len(ini.Lists))
}
ru, ok := ini.Lists["RU"]
if !ok {
t.Fatal("expected RU playlist")
}
if ru.Name != "Russian Playlist" {
t.Errorf("expected 'Russian Playlist', got '%s'", ru.Name)
}
if ru.Description != "Russian channels" {
t.Errorf("expected 'Russian channels', got '%s'", ru.Description)
}
if ru.Url != "http://example.com/ru.m3u" {
t.Errorf("expected URL, got '%s'", ru.Url)
}
if ru.Source != "public" {
t.Errorf("expected 'public', got '%s'", ru.Source)
}
}
func TestInit_NotFound(t *testing.T) {
_, err := Init("/nonexistent/playlists.ini")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestInit_EmptyPls(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[BAD]
name = Bad Playlist
`
os.WriteFile(path, []byte(data), 0644)
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
if len(ini.Lists) != 0 {
t.Errorf("expected 0 playlists (empty pls), got %d", len(ini.Lists))
}
}
func TestInit_DefaultName(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "playlists.ini")
data := `[CODE1]
pls = http://example.com/list.m3u
`
os.WriteFile(path, []byte(data), 0644)
ini, err := Init(path)
if err != nil {
t.Fatal(err)
}
pls, ok := ini.Lists["CODE1"]
if !ok {
t.Fatal("expected CODE1 playlist")
}
if pls.Name != "Playlist #CODE1" {
t.Errorf("expected 'Playlist #CODE1', got '%s'", pls.Name)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
+20 -21
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -54,9 +54,6 @@ type Playlist struct {
CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp
}
// tmpChannel хранит временные данные о канале, который обрабатывается в Parse
var tmpChannel = Channel{}
// MakeFromFile создаёт экземпляр плейлиста из файла
func MakeFromFile(filepath string) (Playlist, error) {
expandedPath, err := utils.ExpandPath(filepath)
@@ -131,8 +128,10 @@ func parseTitle(line string) string {
}
// Download загружает плейлист по URL-адресу
func (pls *Playlist) Download() error {
content, err := utils.Fetch(pls.Url)
// Download скачивает плейлист по URL.
// userAgent и timeout передаются в HTTP-клиент.
func (pls *Playlist) Download(userAgent string, timeout time.Duration) error {
content, err := utils.Fetch(pls.Url, userAgent, timeout)
if err != nil {
pls.Content = err.Error()
pls.CheckedAt = time.Now().Unix()
@@ -159,6 +158,7 @@ func (pls *Playlist) ReadFromFs() error {
// Parse разбирает плейлист
func (pls *Playlist) Parse() Playlist {
isChannel := false
var ch Channel
pls.Attributes = make(map[string]string)
pls.Channels = make(map[string]Channel)
pls.Groups = make(map[string]Group)
@@ -180,20 +180,20 @@ func (pls *Playlist) Parse() Playlist {
if strings.HasPrefix(line, "#EXTINF") {
isChannel = true
tmpChannel.Attributes = parseAttributes(line)
tmpChannel.Title = parseTitle(line)
ch = Channel{Attributes: parseAttributes(line)}
ch.Title = parseTitle(line)
if tmpChannel.Title == "" {
if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok {
tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")"
if ch.Title == "" {
if tvgid, ok := ch.Attributes["tvg-id"]; ok {
ch.Title = "(канал без названия, tvg-id=" + tvgid + ")"
} else {
tmpChannel.Title = "(канал без названия, tvg-id неизвестен)"
ch.Title = "(канал без названия, tvg-id неизвестен)"
}
}
if groupName, ok := tmpChannel.Attributes["group-title"]; ok {
if groupName, ok := ch.Attributes["group-title"]; ok {
id := utils.Md5str(groupName)
tmpChannel.GroupId = id
ch.GroupId = id
pls.Groups[id] = Group{
Id: id,
Name: groupName,
@@ -207,7 +207,7 @@ func (pls *Playlist) Parse() Playlist {
parts := strings.Split(line, ":")
groupName := strings.Trim(parts[1], " ")
id := utils.Md5str(groupName)
tmpChannel.GroupId = id
ch.GroupId = id
pls.Groups[id] = Group{
Id: id,
Name: groupName,
@@ -217,13 +217,12 @@ func (pls *Playlist) Parse() Playlist {
}
if isChannel && strings.HasPrefix(line, "http") {
tmpChannel.URL = strings.Trim(line, " ")
tmpChannel.Id = utils.Md5str(tmpChannel.URL)
if tmpChannel.Id != "" {
pls.Channels[tmpChannel.Id] = tmpChannel
ch.URL = strings.Trim(line, " ")
ch.Id = utils.Md5str(ch.URL)
if ch.Id != "" {
pls.Channels[ch.Id] = ch
isChannel = false
tmpChannel = Channel{}
tmpChannel.Attributes = make(map[string]string)
ch = Channel{}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
package playlist
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
func TestParseAttributes(t *testing.T) {
line := `#EXTINF:-1 tvg-id="123" tvg-name="Channel" group-title="News",Channel Name`
attrs := parseAttributes(line)
if attrs["tvg-id"] != "123" {
t.Errorf("expected tvg-id=123, got %s", attrs["tvg-id"])
}
if attrs["tvg-name"] != "Channel" {
t.Errorf("expected tvg-name=Channel, got %s", attrs["tvg-name"])
}
if attrs["group-title"] != "News" {
t.Errorf("expected group-title=News, got %s", attrs["group-title"])
}
}
func TestParseAttributes_Empty(t *testing.T) {
attrs := parseAttributes("#EXTINF:-1,Plain Channel")
if len(attrs) != 0 {
t.Errorf("expected 0 attrs, got %d", len(attrs))
}
}
func TestParseTitle_WithComma(t *testing.T) {
cases := []struct {
in string
want string
}{
{`#EXTINF:-1 tvg-id="x",My Channel`, "My Channel"},
{`#EXTINF:-1,Hello, World`, "Hello, World"},
{`#EXTINF:-1 tvg-name="Test",Test Channel`, "Test Channel"},
}
for _, c := range cases {
got := parseTitle(c.in)
if got != c.want {
t.Errorf("parseTitle(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseTitle_NoComma(t *testing.T) {
got := parseTitle(`#EXTINF:-1 tvg-name="Foo"`)
if got == "" {
t.Error("expected non-empty title")
}
}
func TestParse_SimplePlaylist(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1" group-title="News",Channel 1
http://example.com/ch1.m3u8
#EXTINF:-1 tvg-id="ch2" tvg-name="Channel 2" group-title="Movies",Channel 2
http://example.com/ch2.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(pls.Channels))
}
if len(pls.Groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(pls.Groups))
}
}
func TestParse_WithBOM(t *testing.T) {
content := "\xef\xbb\xbf#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 1 {
t.Fatalf("expected 1 channel (BOM removed), got %d", len(pls.Channels))
}
}
func TestParse_WithWindowsLineEndings(t *testing.T) {
content := "#EXTM3U\r\n#EXTINF:-1,Test\r\nhttp://example.com/test.m3u8\r\n"
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 1 {
t.Fatalf("expected 1 channel (CRLF), got %d", len(pls.Channels))
}
}
func TestParse_EmptyContent(t *testing.T) {
pls := &Playlist{Content: ""}
pls.Parse()
if len(pls.Channels) != 0 {
t.Errorf("expected 0 channels, got %d", len(pls.Channels))
}
}
func TestParse_Extgrp(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1,Channel A
#EXTGRP:Sports
http://example.com/a.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(pls.Groups))
}
for _, ch := range pls.Channels {
if ch.GroupId == "" {
t.Error("expected non-empty group ID")
}
}
}
func TestParse_ExtinfAttributes(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="abc" tvg-logo="logo.png" group-title="HD",HD Channel
http://example.com/hd.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
for _, ch := range pls.Channels {
if ch.Attributes["tvg-id"] != "abc" {
t.Errorf("expected tvg-id=abc, got %s", ch.Attributes["tvg-id"])
}
if ch.Attributes["tvg-logo"] != "logo.png" {
t.Errorf("expected tvg-logo=logo.png, got %s", ch.Attributes["tvg-logo"])
}
if ch.Title != "HD Channel" {
t.Errorf("expected title 'HD Channel', got '%s'", ch.Title)
}
}
}
func TestParse_ChannelWithoutTitle(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="notitle"
http://example.com/notitle.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
for _, ch := range pls.Channels {
if ch.Title == "" {
t.Error("expected non-empty title for channel without name")
}
}
}
func TestParse_Extm3uAttributes(t *testing.T) {
content := `#EXTM3U url-tvg="http://example.com/epg.xml"
#EXTINF:-1,Test
http://example.com/test.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if pls.Attributes["url-tvg"] != "http://example.com/epg.xml" {
t.Errorf("expected url-tvg attr, got %s", pls.Attributes["url-tvg"])
}
}
func TestMakeFromUrl(t *testing.T) {
pls, err := MakeFromUrl("http://example.com/list.m3u8")
if err != nil {
t.Fatal(err)
}
if pls.Url != "http://example.com/list.m3u8" {
t.Errorf("expected URL, got %s", pls.Url)
}
if pls.Source != "-u" {
t.Errorf("expected source '-u', got '%s'", pls.Source)
}
if !pls.IsOnline {
t.Error("expected IsOnline=true")
}
}
func TestMakeFromFile_NotFound(t *testing.T) {
_, err := MakeFromFile("/nonexistent/file.m3u")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestMakeFromFile_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.m3u")
content := "#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
pls, err := MakeFromFile(path)
if err != nil {
t.Fatal(err)
}
if pls.Source != "-f" {
t.Errorf("expected source '-f', got '%s'", pls.Source)
}
if pls.Content != content {
t.Error("content mismatch")
}
}
func TestReadFromFs(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.m3u")
content := "#EXTM3U\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
pls := &Playlist{Url: path}
if err := pls.ReadFromFs(); err != nil {
t.Fatal(err)
}
if pls.Content != content {
t.Errorf("expected '%s', got '%s'", content, pls.Content)
}
}
func TestReadFromFs_NotFound(t *testing.T) {
pls := &Playlist{Url: "/nonexistent/file.m3u"}
err := pls.ReadFromFs()
if err == nil {
t.Error("expected error for non-existent file")
}
}
// TestParseConcurrent проверяет, что парсинг нескольких плейлистов
// в параллельных горутинах не приводит к потере каналов из-за data race.
// До фикса tmpChannel была пакетной переменной, и каналы терялись.
func TestParseConcurrent(t *testing.T) {
const goroutines = 20
const channelsPerPlaylist = 50
// генерируем плейлист с channelsPerPlaylist каналами
content := "#EXTM3U\n"
for i := 0; i < channelsPerPlaylist; i++ {
content += fmt.Sprintf("#EXTINF:-1 tvg-id=\"ch%d\",Channel %d\n", i, i)
content += fmt.Sprintf("http://example.com/stream%d.m3u8\n", i)
}
var wg sync.WaitGroup
wg.Add(goroutines)
lost := make([]int, goroutines)
for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
pls := &Playlist{Content: content}
pls.Parse()
lost[idx] = channelsPerPlaylist - len(pls.Channels)
}(i)
}
wg.Wait()
totalLost := 0
for _, l := range lost {
totalLost += l
}
if totalLost > 0 {
t.Errorf("data race detected: %d channels lost across %d goroutines (per-goroutine losses: %v)",
totalLost, goroutines, lost)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
+163
View File
@@ -0,0 +1,163 @@
package tagfile
import (
"os"
"path/filepath"
"testing"
"axenov/iptv-checker/app/playlist"
)
func TestGetTags_TvgId(t *testing.T) {
block := TagBlock{
TvgId: "^ru-",
Tags: []string{"russian"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "ru-first"},
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "russian" {
t.Errorf("expected [russian], got %v", tags)
}
}
func TestGetTags_TvgId_NoMatch(t *testing.T) {
block := TagBlock{
TvgId: "^en-",
Tags: []string{"english"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "ru-first"},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_TvgId_NoAttribute(t *testing.T) {
block := TagBlock{
TvgId: "^ru-",
Tags: []string{"russian"},
}
ch := playlist.Channel{
Attributes: map[string]string{},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_TvgName(t *testing.T) {
block := TagBlock{
TvgName: "sport",
Tags: []string{"sports"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-name": "Sports Channel"},
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "sports" {
t.Errorf("expected [sports], got %v", tags)
}
}
func TestGetTags_Title(t *testing.T) {
block := TagBlock{
Title: "movie",
Tags: []string{"movies"},
}
ch := playlist.Channel{
Title: "Best Movie HD",
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "movies" {
t.Errorf("expected [movies], got %v", tags)
}
}
func TestGetTags_TitleCaseInsensitive(t *testing.T) {
block := TagBlock{
Title: "hd",
Tags: []string{"hd"},
}
ch := playlist.Channel{
Title: "some HD channel",
}
tags := block.GetTags(ch)
if len(tags) != 1 || tags[0] != "hd" {
t.Errorf("expected [hd], got %v", tags)
}
}
func TestGetTags_EmptyBlock(t *testing.T) {
block := TagBlock{}
ch := playlist.Channel{
Title: "Test",
Attributes: map[string]string{},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty, got %v", tags)
}
}
func TestGetTags_InvalidRegex(t *testing.T) {
block := TagBlock{
TvgId: "[invalid",
Tags: []string{"bad"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": "test"},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty for invalid regex, got %v", tags)
}
}
func TestGetTags_TvgIdEmptyValue(t *testing.T) {
block := TagBlock{
TvgId: "test",
Tags: []string{"tag"},
}
ch := playlist.Channel{
Attributes: map[string]string{"tvg-id": ""},
}
tags := block.GetTags(ch)
if len(tags) != 0 {
t.Errorf("expected empty for empty tvg-id value, got %v", tags)
}
}
func TestInit_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "channels.json")
data := `[{"tvg-id":"^ru-","tags":["russian"]},{"title":"movie","tags":["movies"]}]`
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatal(err)
}
blocks := Init(path)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d", len(blocks))
}
}
func TestInit_NotFound(t *testing.T) {
blocks := Init("/nonexistent/channels.json")
if blocks != nil {
t.Errorf("expected nil for non-existent file, got %v", blocks)
}
}
func TestInit_InvalidJSON(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "channels.json")
os.WriteFile(path, []byte("not json"), 0644)
blocks := Init(path)
if blocks != nil {
t.Errorf("expected nil for invalid JSON, got %v", blocks)
}
}
+8 -5
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -44,16 +44,19 @@ func ArrayUnique(arr []string) []string {
return result
}
// Fetch выполняет GET запрос и возвращает результат в виде массива байт
func Fetch(url string) ([]byte, error) {
// Fetch выполняет GET запрос и возвращает результат в виде массива байт.
// userAgent — значение заголовка User-Agent, timeout — таймаут запроса.
func Fetch(url string, userAgent string, timeout time.Duration) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 WINK/1.31.1 (AndroidTV/9) HlsWinkPlayer")
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
httpClient := http.Client{Timeout: 10 * time.Second}
httpClient := http.Client{Timeout: timeout}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
+155
View File
@@ -0,0 +1,155 @@
package utils
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestMd5str(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "d41d8cd98f00b204e9800998ecf8427e"},
{"hello", "5d41402abc4b2a76b9719d911017c592"},
{"test", "098f6bcd4621d373cade4e832627b4f6"},
}
for _, c := range cases {
got := Md5str(c.in)
if got != c.want {
t.Errorf("Md5str(%q) = %s, want %s", c.in, got, c.want)
}
}
}
func TestMd5str_Consistency(t *testing.T) {
h1 := Md5str("same")
h2 := Md5str("same")
if h1 != h2 {
t.Error("Md5str should be deterministic")
}
}
func TestArrayUnique(t *testing.T) {
in := []string{"a", "b", "a", "c", "b", "d"}
got := ArrayUnique(in)
if len(got) != 4 {
t.Fatalf("expected 4, got %d: %v", len(got), got)
}
seen := map[string]bool{}
for _, v := range got {
if seen[v] {
t.Errorf("duplicate found: %s", v)
}
seen[v] = true
}
}
func TestArrayUnique_Empty(t *testing.T) {
got := ArrayUnique([]string{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
}
func TestArrayUnique_Single(t *testing.T) {
got := ArrayUnique([]string{"only"})
if len(got) != 1 || got[0] != "only" {
t.Errorf("expected [only], got %v", got)
}
}
func TestArrayUnique_AllSame(t *testing.T) {
got := ArrayUnique([]string{"x", "x", "x"})
if len(got) != 1 || got[0] != "x" {
t.Errorf("expected [x], got %v", got)
}
}
func TestExpandPath_Relative(t *testing.T) {
got, err := ExpandPath("foo/bar")
if err != nil {
t.Fatal(err)
}
if got == "" {
t.Error("expected non-empty path")
}
// should be absolute
if got[0] != '/' {
t.Errorf("expected absolute path, got %s", got)
}
}
func TestExpandPath_Tilde(t *testing.T) {
got, err := ExpandPath("~/test")
if err != nil {
t.Fatal(err)
}
if got == "" {
t.Error("expected non-empty path")
}
// should not contain ~
for _, c := range got {
if c == '~' {
t.Error("path should not contain ~")
break
}
}
}
func TestFetch_Success(t *testing.T) {
body := "hello world"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ua := r.Header.Get("User-Agent"); ua != "TestUA" {
t.Errorf("expected User-Agent 'TestUA', got '%s'", ua)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
}))
defer srv.Close()
data, err := Fetch(srv.URL, "TestUA", 5*time.Second)
if err != nil {
t.Fatal(err)
}
if string(data) != body {
t.Errorf("expected '%s', got '%s'", body, string(data))
}
}
func TestFetch_NonOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := Fetch(srv.URL, "", 5*time.Second)
if err == nil {
t.Error("expected error for non-200 status")
}
}
func TestFetch_EmptyUserAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
data, err := Fetch(srv.URL, "", 5*time.Second)
if err != nil {
t.Fatal(err)
}
if string(data) != "ok" {
t.Errorf("expected 'ok', got '%s'", string(data))
}
}
func TestFetch_InvalidURL(t *testing.T) {
_, err := Fetch("http://[::1]:named", "", 1*time.Second)
if err == nil {
t.Error("expected error for invalid URL")
}
}
+255
View File
@@ -0,0 +1,255 @@
package web
import (
"axenov/iptv-checker/app/config"
"axenov/iptv-checker/app/playlist"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// makeTestServer создаёт HTTP-сервер с тестовыми плейлистами в memCache.
// Возвращает сервер и обёртку для добавления плейлистов в memCache.
func makeTestServer(t *testing.T) (*httptest.Server, *Server) {
t.Helper()
cfg := config.Init("")
cfg.App.Playlists = "testdata/playlists.ini"
srv := NewServer(cfg, nil)
mux := http.NewServeMux()
// API routes
mux.HandleFunc("GET /api/playlists", srv.handleAPIGetAll)
mux.HandleFunc("GET /api/playlists/{code}", srv.handleAPIGetOne)
mux.HandleFunc("GET /api/playlists/{code}/channels", srv.handleAPIGetChannels)
mux.HandleFunc("GET /api/version", srv.handleAPIVersion)
mux.HandleFunc("GET /api/health", srv.handleAPIHealth)
mux.HandleFunc("GET /api/stats", srv.handleAPIStats)
mux.HandleFunc("GET /api/openapi.json", srv.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))
// page routes
mux.HandleFunc("GET /{$}", srv.handleHome)
mux.HandleFunc("GET /{path...}", srv.handleRoute)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, srv
}
// putPlaylist кладёт плейлист в in-memory кеш сервера
func (s *Server) putPlaylist(pls playlist.Playlist) {
s.memMu.Lock()
defer s.memMu.Unlock()
if s.memCache == nil {
s.memCache = make(map[string]playlist.Playlist)
}
s.memCache[pls.Code] = pls
}
// makeOnlinePlaylist создаёт тестовый плейлист с онлайн/оффлайн каналами
func makeOnlinePlaylist() playlist.Playlist {
return playlist.Playlist{
Code: "test1",
Name: "Test Playlist 1",
Description: "A test playlist",
Url: "http://example.com/test1.m3u8",
Source: "test source",
IsOnline: true,
CheckedAt: 1700000000,
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
Groups: map[string]playlist.Group{
"group1": {Id: "group1", Name: "Group One"},
"group2": {Id: "group2", Name: "Group Two"},
},
Channels: map[string]playlist.Channel{
"id1": {
Id: "id1",
Title: "Channel One",
URL: "http://example.com/stream1.m3u8",
GroupId: "group1",
Attributes: map[string]string{"tvg-id": "ch1", "tvg-logo": "http://example.com/logo1.png"},
Status: 200,
IsOnline: true,
Tags: []string{"hd"},
},
"id2": {
Id: "id2",
Title: "Channel Two",
URL: "http://example.com/stream2.m3u8",
GroupId: "group2",
Attributes: map[string]string{"tvg-id": "ch2", "tvg-logo": "http://example.com/logo2.png"},
Status: 404,
IsOnline: false,
Tags: []string{"untagged"},
},
"id3": {
Id: "id3",
Title: "Channel Three",
URL: "http://example.com/stream3.m3u8",
GroupId: "group1",
Attributes: map[string]string{"tvg-id": "ch3", "tvg-logo": "http://example.com/logo3.png"},
Status: 200,
IsOnline: true,
Tags: []string{"hd", "news"},
},
},
OnlineCount: 2,
OfflineCount: 1,
}
}
// fetchBody читает тело HTTP-ответа целиком и возвращает его как строку
func fetchBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %s", err)
}
return string(b)
}
// TestDetailsPageRendersChannelsTable проверяет, что на странице деталей
// онлайн-плейлиста присутствует таблица с каналами и данными для list.js
func TestDetailsPageRendersChannelsTable(t *testing.T) {
ts, srv := makeTestServer(t)
srv.putPlaylist(makeOnlinePlaylist())
// Главная страница должна быть доступна
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET /: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("GET /: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Страница деталей — основной сценарий
resp, err = http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /test1/details: expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
checks := []struct {
name string
want bool
}{
// Заголовок и базовая структура
{"заголовок 'Список каналов'", strings.Contains(body, "Список каналов")},
{"id таблицы 'chlist'", strings.Contains(body, `id="chlist"`)},
{"класс 'list' у tbody", strings.Contains(body, `class="list"`)},
{"chcount = 3", strings.Contains(body, `<span id="chcount">3</span>`)},
// Данные каналов в JSON
{"values JSON", strings.Contains(body, "const values =")},
{"Channel One в JSON", strings.Contains(body, "Channel One")},
{"Channel Two в JSON", strings.Contains(body, "Channel Two")},
{"Channel Three в JSON", strings.Contains(body, "Channel Three")},
// Группы (отображаются когда > 1)
{"id группы group1", strings.Contains(body, `value="group1"`)},
{"id группы group2", strings.Contains(body, `value="group2"`)},
// Теги
{"тег #hd", strings.Contains(body, "btn-tag-hd")},
{"тег #news", strings.Contains(body, "btn-tag-news")},
{"тег #untagged", strings.Contains(body, "btn-tag-untagged")},
// list.js
{"list.js подключён", strings.Contains(body, "list.js@2.3.1")},
{"getChannelTemplate функция", strings.Contains(body, "function getChannelTemplate")},
{"опции list.js", strings.Contains(body, "const options =")},
{"new List('chlist')", strings.Contains(body, "new List('chlist'")},
{"getChannelTemplate возвращает <tr>", strings.Contains(body, "chrow")},
// Счётчики
{"OnlineCount отображается (~66%)", strings.Contains(body, "2 (66%)") || strings.Contains(body, "2 (67%)")},
{"OfflineCount отображается (~33%)", strings.Contains(body, "1 (33%)") || strings.Contains(body, "1 (34%)")},
}
for _, c := range checks {
if !c.want {
t.Errorf("page missing: %s", c.name)
}
}
}
// TestDetailsPageOfflinePlaylist проверяет, что для оффлайн-плейлиста
// (IsKnown=true, IsOnline=false) вместо таблицы каналов показывается
// сообщение об ошибке
func TestDetailsPageOfflinePlaylist(t *testing.T) {
ts, srv := makeTestServer(t)
srv.putPlaylist(playlist.Playlist{
Code: "test1",
Name: "Test Playlist 1",
Url: "http://example.com/test1.m3u8",
IsOnline: false,
Content: "HTTP status 404",
CheckedAt: 1700000000,
})
resp, err := http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "Ошибка плейлиста") {
t.Error("offline playlist should show error message")
}
if !strings.Contains(body, "HTTP status 404") {
t.Error("offline playlist should show error text from Content")
}
if strings.Contains(body, `id="chlist"`) {
t.Error("offline playlist should NOT render channels table")
}
}
// TestDetailsPageUnknownPlaylist проверяет, что для непроверенного плейлиста
// (IsKnown=false) вместо таблицы каналов показывается предупреждение
func TestDetailsPageUnknownPlaylist(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "Список каналов сейчас неизвестен") {
t.Error("unknown playlist should show 'list unknown' warning")
}
if strings.Contains(body, `id="chlist"`) {
t.Error("unknown playlist should NOT render channels table")
}
}
// TestDetailsPageNonexistentPlaylist проверяет, что несуществующий код
// возвращает 404
func TestDetailsPageNonexistentPlaylist(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/nonexistent/details")
if err != nil {
t.Fatalf("GET /nonexistent/details: %s", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
resp.Body.Close()
}
+528
View File
@@ -0,0 +1,528 @@
/*
* 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"
"axenov/iptv-checker/app/playlist"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// handleHome — главная страница со списком плейлистов
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
playlists, updatedAt, err := s.getPlaylistsView()
errorMsg := ""
if err != nil {
log.Printf("Error loading playlists: %s", err)
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
playlists = []PlaylistView{}
updatedAt = ""
}
count := len(playlists)
pageSize := int(s.cfg.Site.PageSize)
pageCurrent := 1
pageCount := 1
// pagination
if pageSize > 0 && count > pageSize {
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
if pageArg := r.PathValue("page"); pageArg != "" {
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
pageCurrent = p
}
}
offset := (pageCurrent - 1) * pageSize
if offset >= count {
offset = 0
pageCurrent = 1
}
end := offset + pageSize
if end > count {
end = count
}
playlists = playlists[offset:end]
}
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
UpdatedAt: updatedAt,
Navigation: s.cfg.Site.Header.Navigation,
FooterLinks: s.cfg.Site.FooterLinks,
Playlists: playlists,
Count: count,
PageCount: pageCount,
PageCurrent: pageCurrent,
Error: errorMsg,
}
s.templates.Render(w, "list", data)
}
// handleDetails — страница с описанием плейлиста
func (s *Server) handleDetails(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.renderNotFound(w, r, "")
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.renderNotFound(w, r, code)
return
}
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
Navigation: s.cfg.Site.Header.Navigation,
FooterLinks: s.cfg.Site.FooterLinks,
Playlist: view,
}
s.templates.Render(w, "details", data)
}
// handleRoute — единый обработчик для не-API маршрутов
// Разбирает путь и направляет на нужный обработчик:
// /page/N → главная с пагинацией
// /CODE/details → страница плейлиста
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
// остальное → 404
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
if path == "" {
s.handleHome(w, r)
return
}
segments := strings.Split(path, "/")
switch len(segments) {
case 1:
// /page/N → пагинация
if segments[0] == "page" {
s.renderNotFound(w, r, "")
return
}
if strings.HasPrefix(segments[0], "page/") {
// shouldn't happen with single segment, but just in case
pageStr := strings.TrimPrefix(segments[0], "page/")
if _, err := strconv.Atoi(pageStr); err == nil {
r.SetPathValue("page", pageStr)
s.handleHome(w, r)
return
}
}
// /CODE or /CODE.m3u[8] → redirect
s.handleRedirect(w, r, segments[0])
case 2:
// /page/N → пагинация
if segments[0] == "page" {
if _, err := strconv.Atoi(segments[1]); err == nil {
r.SetPathValue("page", segments[1])
s.handleHome(w, r)
return
}
s.renderNotFound(w, r, "")
return
}
// /CODE/details → страница плейлиста
if segments[1] == "details" {
r.SetPathValue("code", segments[0])
s.handleDetails(w, r)
return
}
s.renderNotFound(w, r, segments[0])
default:
s.renderNotFound(w, r, "")
}
}
// handleRedirect — перенаправляет на URL плейлиста по коду
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
// strip .m3u / .m3u8 suffix
code := path
code = strings.TrimSuffix(code, ".m3u8")
code = strings.TrimSuffix(code, ".m3u")
// validate: alphanumeric
if !isAlphanumeric(code) {
s.renderNotFound(w, r, code)
return
}
// look up in ini
ini, err := s.loadIni()
if err != nil {
s.renderNotFound(w, r, code)
return
}
pls, exists := ini.Lists[code]
if !exists || pls.Url == "" {
s.renderNotFound(w, r, code)
return
}
http.Redirect(w, r, pls.Url, http.StatusFound)
}
// handleAPIGetAll — API: список плейлистов
func (s *Server) handleAPIGetAll(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
items := make([]map[string]interface{}, 0, len(playlists))
for _, view := range playlists {
items = append(items, buildAPIPlaylist(view))
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": items,
})
}
// handleAPIGetOne — API: информация о плейлисте
func (s *Server) handleAPIGetOne(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
s.jsonResponse(w, http.StatusOK, buildAPIPlaylist(*view))
}
// handleAPIGetChannels — API: каналы плейлиста
func (s *Server) handleAPIGetChannels(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
channels := make([]map[string]interface{}, 0, len(view.Channels))
for _, ch := range view.ViewChannels {
channels = append(channels, map[string]interface{}{
"id": ch.Id,
"title": ch.Title,
"url": ch.URL,
"groupId": ch.GroupId,
"attributes": mapToAttributeArray(ch.Attributes),
"status": ch.Status,
"isOnline": ch.IsOnline,
"error": ch.Error,
"contentType": ch.ContentType,
"tags": ch.Tags,
"checkedAt": ch.CheckedAt,
"hasToken": ch.HasToken,
})
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": channels,
})
}
// buildAPIPlaylist формирует JSON-представление плейлиста для API
func buildAPIPlaylist(view PlaylistView) map[string]interface{} {
resp := map[string]interface{}{
"code": view.Code,
"name": view.Name,
"description": view.Description,
"url": view.Url,
"source": view.Source,
"isOnline": nil,
"onlineCount": view.OnlineCount,
"offlineCount": view.OfflineCount,
"checkedAt": view.CheckedAt,
"attributes": mapToAttributeArray(view.Attributes),
"groups": groupsToArray(view.Groups),
"tags": view.Tags,
}
if view.IsKnown {
resp["isOnline"] = view.IsOnline
if view.IsOnline {
delete(resp, "content")
} else {
resp["content"] = view.Content
}
}
return resp
}
// mapToAttributeArray преобразует map атрибутов в массив {name, value}
func mapToAttributeArray(attrs map[string]string) []map[string]string {
result := make([]map[string]string, 0, len(attrs))
for name, value := range attrs {
result = append(result, map[string]string{
"name": name,
"value": value,
})
}
return result
}
// groupsToArray преобразует map групп в массив объектов
func groupsToArray(groups map[string]playlist.Group) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
result = append(result, map[string]interface{}{
"id": g.Id,
"name": g.Name,
"attributes": mapToAttributeArray(g.Attributes),
})
}
return result
}
// handleAPIVersion — API: версия
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"iptvc": app.VERSION,
}
if s.cache != nil {
ctx := r.Context()
info, err := s.cache.Info(ctx, "server").Result()
if err == nil {
resp["keydb"] = parseRedisVersion(info)
}
}
s.jsonResponse(w, http.StatusOK, resp)
}
// handleAPIHealth — API: состояние
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
if s.cache != nil {
ctx := r.Context()
health["redis"] = map[string]interface{}{
"isConnected": s.cache.Ping(ctx).Err() == nil,
}
} else {
health["redis"] = map[string]interface{}{
"isConnected": false,
}
}
health["iniFile"] = map[string]interface{}{
"path": s.cfg.App.Playlists,
}
s.jsonResponse(w, http.StatusOK, health)
}
// handleAPIStats — API: статистика
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
stats := computeStats(playlists)
s.jsonResponse(w, http.StatusOK, stats)
}
// renderNotFound — отображает страницу 404
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
w.WriteHeader(http.StatusNotFound)
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
Navigation: s.cfg.Site.Header.Navigation,
FooterLinks: s.cfg.Site.FooterLinks,
Code: code,
}
s.templates.Render(w, "notfound", data)
}
// jsonResponse отправляет JSON-ответ
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
resp := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
if m, ok := data.(map[string]interface{}); ok {
for k, v := range m {
resp[k] = v
}
} else {
resp["data"] = data
}
json.NewEncoder(w).Encode(resp)
}
// jsonError отправляет JSON-ошибку
func (s *Server) jsonError(w http.ResponseWriter, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"timestamp": time.Now().Unix(),
"error": map[string]string{
"message": err.Error(),
},
})
}
// isAlphanumeric проверяет, состоит ли строка только из букв и цифр
func isAlphanumeric(s string) bool {
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
return false
}
}
return len(s) > 0
}
// parseRedisVersion извлекает версию Redis из INFO-ответа
func parseRedisVersion(info string) string {
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "redis_version:") {
return strings.TrimPrefix(line, "redis_version:")
}
}
return "unknown"
}
// computeStats вычисляет статистику по плейлистам и каналам
func computeStats(playlists []PlaylistView) map[string]interface{} {
plsOnline := 0
plsOffline := 0
plsUnknown := 0
plsAdult := 0
plsCatchup := 0
plsTvg := 0
plsGroupped := 0
channelsAll := 0
channelsOnline := 0
channelsOffline := 0
channelsAdult := 0
var latestCode string
var latestTime int64
for _, pls := range playlists {
if !pls.IsKnown {
plsUnknown++
} else if pls.IsOnline {
plsOnline++
} else {
plsOffline++
}
if containsTag(pls.Tags, "adult") {
plsAdult++
}
if pls.HasCatchup {
plsCatchup++
}
if pls.HasTvg {
plsTvg++
}
if len(pls.Groups) > 0 {
plsGroupped++
}
if pls.CheckedAt > latestTime {
latestTime = pls.CheckedAt
latestCode = pls.Code
}
for _, ch := range pls.Channels {
channelsAll++
if ch.IsOnline {
channelsOnline++
} else {
channelsOffline++
}
if containsTag(ch.Tags, "adult") {
channelsAdult++
}
}
}
latest := map[string]interface{}{
"code": latestCode,
"time": latestTime,
}
if latestTime > 0 {
latest["timeFmt"] = time.Unix(latestTime, 0).Format("15:04:05 02.01.2006")
}
return map[string]interface{}{
"playlists": map[string]interface{}{
"all": len(playlists),
"online": plsOnline,
"offline": plsOffline,
"unknown": plsUnknown,
"adult": plsAdult,
"hasCatchup": plsCatchup,
"hasTvg": plsTvg,
"groupped": plsGroupped,
"latest": latest,
},
"channels": map[string]interface{}{
"all": channelsAll,
"online": channelsOnline,
"offline": channelsOffline,
"adult": channelsAdult,
},
}
}
// containsTag проверяет наличие тега в списке
func containsTag(tags []string, tag string) bool {
for _, t := range tags {
if t == tag {
return true
}
}
return false
}
+432
View File
@@ -0,0 +1,432 @@
/*
* 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"
"embed"
"encoding/json"
"io/fs"
"net/http"
)
//go:embed swagger
var swaggerFS embed.FS
// handleOpenAPISpec — отдаёт динамически сгенерированную OpenAPI 3.0 спецификацию
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
spec := s.generateOpenAPISpec()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spec)
}
// swaggerFileServer раздаёт встроенные файлы Swagger UI (CSS, JS, HTML)
var swaggerFileServer http.Handler
func init() {
sub, err := fs.Sub(swaggerFS, "swagger")
if err != nil {
panic("cannot create sub-FS for swagger: " + err.Error())
}
swaggerFileServer = http.FileServer(http.FS(sub))
}
// generateOpenAPISpec строит OpenAPI 3.0.3 спецификацию динамически
func (s *Server) generateOpenAPISpec() map[string]interface{} {
return map[string]interface{}{
"openapi": "3.0.3",
"info": map[string]interface{}{
"title": s.cfg.Site.Header.Title + " API",
"version": app.VERSION,
"description": "API для проверки IPTV-плейлистов и доступности каналов.",
"license": map[string]interface{}{
"name": "MIT",
"url": "https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE",
},
},
"servers": []map[string]interface{}{
{
"url": s.cfg.Site.BaseUrl,
"description": "Текущий сервер",
},
},
"paths": map[string]interface{}{
"/api/playlists": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Список плейлистов",
"description": "Возвращает список всех плейлистов из ini-файла с результатами проверки.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Список плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistListResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Информация о плейлисте",
"description": "Возвращает информацию о плейлисте по его коду, включая группы, атрибуты и результаты проверки.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о плейлисте",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}/channels": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Каналы плейлиста",
"description": "Возвращает каналы плейлиста по его коду.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Каналы плейлиста",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ChannelListResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/version": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Версии компонентов",
"description": "Возвращает версию iptvc и (при наличии) версию KeyDB/Redis.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о версиях",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/VersionResponse"},
},
},
},
},
},
},
"/api/health": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Состояние сервиса",
"description": "Возвращает состояние подключения к KeyDB/Redis и путь к ini-файлу.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Состояние сервиса",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/HealthResponse"},
},
},
},
},
},
},
"/api/stats": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Statistics"},
"summary": "Статистика",
"description": "Возвращает агрегированную статистику по плейлистам и каналам.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Статистика",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/StatsResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
},
"components": map[string]interface{}{
"schemas": map[string]interface{}{
"PlaylistListResponse": map[string]interface{}{
"type": "object",
"description": "Список плейлистов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Playlist"},
"description": "Массив плейлистов",
},
},
},
"PlaylistResponse": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте с результатами проверки",
"allOf": []map[string]interface{}{
{"$ref": "#/components/schemas/Playlist"},
},
},
"Playlist": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string", "description": "Код плейлиста"},
"name": map[string]interface{}{"type": "string", "description": "Название плейлиста"},
"description": map[string]interface{}{"type": "string", "description": "Описание плейлиста"},
"url": map[string]interface{}{"type": "string", "description": "URL плейлиста"},
"source": map[string]interface{}{"type": "string", "description": "Источник плейлиста"},
"isOnline": map[string]interface{}{
"type": "boolean",
"nullable": true,
"description": "Доступность плейлиста (null если не проверялся)",
},
"onlineCount": map[string]interface{}{"type": "integer", "description": "Количество рабочих каналов"},
"offlineCount": map[string]interface{}{"type": "integer", "description": "Количество нерабочих каналов"},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp последней проверки"},
"content": map[string]interface{}{
"type": "string",
"description": "Содержимое плейлиста (только для оффлайн-плейлистов)",
},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTM3U",
},
"groups": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Group"},
"description": "Группы каналов",
},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги плейлиста",
},
},
},
"ChannelListResponse": map[string]interface{}{
"type": "object",
"description": "Список каналов плейлиста",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Channel"},
"description": "Массив каналов",
},
},
},
"Channel": map[string]interface{}{
"type": "object",
"description": "Информация о канале и статусе его проверки",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш URL канала"},
"title": map[string]interface{}{"type": "string", "description": "Название канала"},
"url": map[string]interface{}{"type": "string", "description": "URL потока канала"},
"groupId": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"hasToken": map[string]interface{}{"type": "boolean", "description": "Признак наличия токена/ключа в URL"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTINF",
},
"status": map[string]interface{}{"type": "integer", "description": "HTTP-код статуса"},
"isOnline": map[string]interface{}{"type": "boolean", "description": "Доступность канала (status < 400)"},
"error": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
"contentType": map[string]interface{}{"type": "string", "description": "MIME-тип ответа"},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги канала",
},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp проверки"},
},
},
"Group": map[string]interface{}{
"type": "object",
"description": "Группа каналов",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"name": map[string]interface{}{"type": "string", "description": "Название группы"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTGRP",
},
},
},
"Attribute": map[string]interface{}{
"type": "object",
"description": "Именованный атрибут тега",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "Имя атрибута"},
"value": map[string]interface{}{"type": "string", "description": "Значение атрибута"},
},
},
"VersionResponse": map[string]interface{}{
"type": "object",
"description": "Информация о версиях компонентов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"iptvc": map[string]interface{}{"type": "string", "description": "Версия iptvc"},
"keydb": map[string]interface{}{
"type": "string",
"description": "Версия KeyDB/Redis (если подключён)",
},
},
},
"HealthResponse": map[string]interface{}{
"type": "object",
"description": "Состояние сервиса",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"redis": map[string]interface{}{
"type": "object",
"description": "Состояние подключения к KeyDB/Redis",
"properties": map[string]interface{}{
"isConnected": map[string]interface{}{"type": "boolean", "description": "Подключение установлено"},
},
},
"iniFile": map[string]interface{}{
"type": "object",
"description": "Информация об ini-файле",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Путь к playlists.ini"},
},
},
},
},
"StatsResponse": map[string]interface{}{
"type": "object",
"description": "Агрегированная статистика",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"playlists": map[string]interface{}{
"type": "object",
"description": "Статистика по плейлистам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"unknown": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
"hasCatchup": map[string]interface{}{"type": "integer"},
"hasTvg": map[string]interface{}{"type": "integer"},
"groupped": map[string]interface{}{"type": "integer"},
"latest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string"},
"time": map[string]interface{}{"type": "integer"},
"timeFmt": map[string]interface{}{"type": "string"},
},
},
},
},
"channels": map[string]interface{}{
"type": "object",
"description": "Статистика по каналам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
},
},
},
},
"ErrorResponse": map[string]interface{}{
"type": "object",
"description": "Ошибка",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"error": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
},
},
},
},
},
},
}
}
+171
View File
@@ -0,0 +1,171 @@
package web
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// TestOpenAPISpec проверяет, что /api/openapi.json отдаёт валидную спецификацию
func TestOpenAPISpec(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/api/openapi.json")
if err != nil {
t.Fatalf("GET /api/openapi.json: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("expected application/json content-type, got %s", ct)
}
var spec map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
t.Fatalf("cannot decode spec: %s", err)
}
// базовые поля OpenAPI 3.0
if v, ok := spec["openapi"].(string); !ok || !strings.HasPrefix(v, "3.0") {
t.Errorf("expected openapi 3.0.x, got %v", spec["openapi"])
}
info, ok := spec["info"].(map[string]interface{})
if !ok {
t.Fatal("missing 'info' section")
}
if _, ok := info["title"].(string); !ok {
t.Error("missing info.title")
}
if _, ok := info["version"].(string); !ok {
t.Error("missing info.version")
}
// сервер должен быть указан
servers, ok := spec["servers"].([]interface{})
if !ok || len(servers) == 0 {
t.Error("expected at least one server")
}
// пути
paths, ok := spec["paths"].(map[string]interface{})
if !ok {
t.Fatal("missing 'paths' section")
}
expectedPaths := []string{
"/api/playlists",
"/api/playlists/{code}",
"/api/playlists/{code}/channels",
"/api/version",
"/api/health",
"/api/stats",
}
for _, p := range expectedPaths {
if _, ok := paths[p]; !ok {
t.Errorf("missing path %s in spec", p)
}
}
// компоненты/схемы
components, ok := spec["components"].(map[string]interface{})
if !ok {
t.Fatal("missing 'components' section")
}
schemas, ok := components["schemas"].(map[string]interface{})
if !ok {
t.Fatal("missing 'components.schemas' section")
}
expectedSchemas := []string{
"PlaylistListResponse",
"PlaylistResponse",
"Playlist",
"ChannelListResponse",
"Channel",
"Group",
"Attribute",
"VersionResponse",
"HealthResponse",
"StatsResponse",
"ErrorResponse",
}
for _, s := range expectedSchemas {
if _, ok := schemas[s]; !ok {
t.Errorf("missing schema %s in components.schemas", s)
}
}
}
// TestSwaggerUI проверяет, что /api/ отдаёт HTML-страницу Swagger UI
// и что CSS/JS-ассеты доступны без внешних зависимостей
func TestSwaggerUI(t *testing.T) {
ts, _ := makeTestServer(t)
// /api → редирект на /api/
noRedirect := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := noRedirect.Get(ts.URL + "/api")
if err != nil {
t.Fatalf("GET /api: %s", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("expected 301 redirect, got %d", resp.StatusCode)
}
// /api/ → HTML-страница
resp, err = http.Get(ts.URL + "/api/")
if err != nil {
t.Fatalf("GET /api/: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("expected text/html content-type, got %s", ct)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "swagger-ui") {
t.Error("Swagger UI page should contain 'swagger-ui' reference")
}
if !strings.Contains(body, "/api/openapi.json") {
t.Error("Swagger UI page should reference /api/openapi.json")
}
// проверяем, что нет ссылок на CDN
if strings.Contains(body, "cdn.jsdelivr.net") || strings.Contains(body, "unpkg.com") {
t.Error("Swagger UI page should not reference any CDN")
}
// CSS-ассет
respCSS, err := http.Get(ts.URL + "/api/swagger-ui.css")
if err != nil {
t.Fatalf("GET /api/swagger-ui.css: %s", err)
}
defer respCSS.Body.Close()
if respCSS.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for CSS, got %d", respCSS.StatusCode)
}
if ct := respCSS.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("expected text/css content-type, got %s", ct)
}
// JS-ассет
respJS, err := http.Get(ts.URL + "/api/swagger-ui-bundle.js")
if err != nil {
t.Fatalf("GET /api/swagger-ui-bundle.js: %s", err)
}
defer respJS.Body.Close()
if respJS.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for JS, got %d", respJS.StatusCode)
}
if ct := respJS.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("expected javascript content-type, got %s", ct)
}
}
+211
View File
@@ -0,0 +1,211 @@
/*
* 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(),
)
}
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swagger UI — IPTV Checker API</title>
<link rel="stylesheet" href="./swagger-ui.css">
<style>
body { margin: 0 }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: window.location.origin + '/api/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'BaseLayout',
});
};
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+176
View File
@@ -0,0 +1,176 @@
/*
* 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/config"
"bytes"
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
"strconv"
"time"
)
//go:embed views/*.html
var viewsFS embed.FS
// TemplateManager управляет загрузкой и рендерингом шаблонов
type TemplateManager struct {
templates map[string]*template.Template
funcMap template.FuncMap
}
// NewTemplateManager создаёт новый менеджер шаблонов
func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) {
funcMap := template.FuncMap{
"baseUrl": func(path string) string {
base := cfg.Site.BaseUrl
if path == "" {
return base
}
return base + "/" + path
},
"formatDate": formatDate,
"formatTimeAgo": formatTimeAgo,
"statusClass": func(v PlaylistView) string {
return statusClass(&v)
},
"statusBadge": func(v PlaylistView) string {
return statusBadge(&v)
},
"jsonChannels": func(channels map[string]ChannelView) template.JS {
arr := make([]ChannelView, 0, len(channels))
for _, ch := range channels {
arr = append(arr, ch)
}
b, _ := json.Marshal(arr)
return template.JS(b)
},
"seq": func(start, end int) []int {
result := make([]int, 0, end-start+1)
for i := start; i <= end; i++ {
result = append(result, i)
}
return result
},
"sub": func(a, b int) int {
return a - b
},
"add": func(a, b int) int {
return a + b
},
"channelCount": func(v PlaylistView) int {
return len(v.Channels)
},
"groupCount": func(v PlaylistView) int {
return len(v.Groups)
},
"hasTag": containsTag,
}
tm := &TemplateManager{
templates: make(map[string]*template.Template),
funcMap: funcMap,
}
// parse base template
baseContent, err := viewsFS.ReadFile("views/base.html")
if err != nil {
return nil, fmt.Errorf("cannot read base.html: %w", err)
}
pages := []string{"list", "details", "notfound"}
for _, page := range pages {
pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page))
if err != nil {
return nil, fmt.Errorf("cannot read %s.html: %w", page, err)
}
tmpl := template.New("base").Funcs(funcMap)
tmpl, err = tmpl.Parse(string(baseContent))
if err != nil {
return nil, fmt.Errorf("cannot parse base template: %w", err)
}
tmpl, err = tmpl.Parse(string(pageContent))
if err != nil {
return nil, fmt.Errorf("cannot parse %s template: %w", page, err)
}
tm.templates[page] = tmpl
}
return tm, nil
}
// Render рендерит шаблон страницы
func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) {
tmpl, ok := tm.templates[page]
if !ok {
http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
log.Printf("Template error (%s): %s", page, err)
http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
// formatTimeAgo возвращает относительное время на русском
func formatTimeAgo(ts int64) string {
if ts == 0 {
return "неизвестно"
}
diff := time.Now().Unix() - ts
if diff < 0 {
diff = 0
}
if diff < 60 {
return "только что"
}
if diff < 3600 {
minutes := diff / 60
return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад"
}
if diff < 86400 {
hours := diff / 3600
return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад"
}
if diff < 604800 {
days := diff / 86400
return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад"
}
return formatDate(ts)
}
// getNounForm возвращает правильную форму существительного для числительного
func getNounForm(number int, form1, form2, form5 string) string {
abs := int(math.Abs(float64(number)))
lastDigit := abs % 10
lastTwoDigits := abs % 100
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
return form5
}
if lastDigit == 1 {
return form1
}
if lastDigit >= 2 && lastDigit <= 4 {
return form2
}
return form5
}
+80
View File
@@ -0,0 +1,80 @@
package web
import (
"testing"
"time"
)
func TestGetNounForm(t *testing.T) {
cases := []struct {
n int
f1 string
f2 string
f5 string
want string
}{
{1, "минуту", "минуты", "минут", "минуту"},
{2, "минуту", "минуты", "минут", "минуты"},
{5, "минуту", "минуты", "минут", "минут"},
{11, "минуту", "минуты", "минут", "минут"},
{21, "минуту", "минуты", "минут", "минуту"},
{22, "минуту", "минуты", "минут", "минуты"},
{25, "минуту", "минуты", "минут", "минут"},
{101, "минуту", "минуты", "минут", "минуту"},
{111, "минуту", "минуты", "минут", "минут"},
{0, "минуту", "минуты", "минут", "минут"},
}
for _, c := range cases {
got := getNounForm(c.n, c.f1, c.f2, c.f5)
if got != c.want {
t.Errorf("getNounForm(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestFormatTimeAgo_Zero(t *testing.T) {
if formatTimeAgo(0) != "неизвестно" {
t.Errorf("expected 'неизвестно' for ts=0")
}
}
func TestFormatTimeAgo_JustNow(t *testing.T) {
now := time.Now().Unix()
got := formatTimeAgo(now)
if got != "только что" {
t.Errorf("expected 'только что', got '%s'", got)
}
}
func TestFormatTimeAgo_Minutes(t *testing.T) {
ts := time.Now().Unix() - 120 // 2 minutes ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected minutes format, got '%s'", got)
}
}
func TestFormatTimeAgo_Hours(t *testing.T) {
ts := time.Now().Unix() - 7200 // 2 hours ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected hours format, got '%s'", got)
}
}
func TestFormatTimeAgo_Days(t *testing.T) {
ts := time.Now().Unix() - 172800 // 2 days ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected days format, got '%s'", got)
}
}
func TestFormatTimeAgo_Future(t *testing.T) {
ts := time.Now().Unix() + 100
got := formatTimeAgo(ts)
// future → diff clamped to 0 → "только что"
if got != "только что" {
t.Errorf("expected 'только что' for future, got '%s'", got)
}
}
+322
View File
@@ -0,0 +1,322 @@
/*
* 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/config"
"axenov/iptv-checker/app/playlist"
"context"
"encoding/json"
"math"
"regexp"
"sort"
"strings"
"time"
)
// PlaylistView — расширенное представление плейлиста для шаблонов
type PlaylistView struct {
playlist.Playlist
IsKnown bool `json:"-"` // true если есть данные в кеше
OnlinePercent int `json:"-"`
OfflinePercent int `json:"-"`
HasCatchup bool `json:"-"`
HasTvg bool `json:"-"`
HasTokens bool `json:"-"`
Tags []string `json:"-"`
ViewChannels map[string]ChannelView `json:"-"`
}
// ChannelView — расширенное представление канала
type ChannelView struct {
playlist.Channel
HasToken bool `json:"hasToken"`
}
// PageData — данные для рендеринга шаблонов
type PageData struct {
Title string
BaseUrl string
RepoUrl string
Version string
UpdatedAt string
Navigation []config.Link
FooterLinks []config.Link
Playlists []PlaylistView
Playlist *PlaylistView
Count int
PageCount int
PageCurrent int
Code string
Error string
}
// tokenPatterns — паттерны для определения нестабильных каналов
var tokenPatterns = []string{
`[?&]token=`,
`[?&]drmreq=`,
`[?&]u=`,
`[?&]user=`,
`[?&]username=`,
`[?&]p=`,
`[?&]pwd=`,
`[?&]password=`,
}
// enrichPlaylist подготавливает данные о плейлисте в расширенном формате,
// аналогично PHP IniFile::initPlaylist
func enrichPlaylist(view *PlaylistView) {
// приколы golang: nil maps
if view.Attributes == nil {
view.Attributes = make(map[string]string)
}
if view.Groups == nil {
view.Groups = make(map[string]playlist.Group)
}
if view.Channels == nil {
view.Channels = make(map[string]playlist.Channel)
}
// проценты онлайн/оффлайн
view.OnlinePercent = 0
view.OfflinePercent = 0
if view.IsKnown && view.IsOnline && len(view.Channels) > 0 {
view.OnlinePercent = int(math.Round(float64(view.OnlineCount) / float64(len(view.Channels)) * 100))
view.OfflinePercent = int(math.Round(float64(view.OfflineCount) / float64(len(view.Channels)) * 100))
}
// наличие catchup
view.HasCatchup = strings.Contains(view.Content, "catchup")
// наличие TVG
view.HasTvg = view.Attributes["url-tvg"] != "" || view.Attributes["x-tvg-url"] != ""
// наличие токенов в плейлисте
view.HasTokens = hasTokensString(view.Url + " " + view.Content)
// сбор тегов и токенов каналов
view.ViewChannels = make(map[string]ChannelView, len(view.Channels))
tagSet := make(map[string]bool)
for id, ch := range view.Channels {
cv := ChannelView{Channel: ch}
cv.HasToken = hasTokensString(ch.URL)
view.ViewChannels[id] = cv
for _, tag := range ch.Tags {
tagSet[tag] = true
}
}
view.Tags = make([]string, 0, len(tagSet))
for tag := range tagSet {
view.Tags = append(view.Tags, tag)
}
sort.Strings(view.Tags)
}
// hasTokensString проверяет наличие токенов/ключей/логинов в строке
func hasTokensString(s string) bool {
if s == "" {
return false
}
for _, pattern := range tokenPatterns {
if matched, _ := regexp.MatchString(pattern, s); matched {
return true
}
}
return false
}
// getPlaylistFromCache возвращает плейлист из кеша Redis по коду
func (s *Server) getPlaylistFromCache(code string) (*PlaylistView, bool) {
if s.cache == nil {
return nil, false
}
ctx := context.Background()
result, err := s.cache.Get(ctx, code).Result()
if err != nil {
return nil, false
}
var pls playlist.Playlist
if err := json.Unmarshal([]byte(result), &pls); err != nil {
return nil, false
}
view := &PlaylistView{Playlist: pls, IsKnown: true}
enrichPlaylist(view)
return view, true
}
// getPlaylistView возвращает представление плейлиста: из in-memory кеша, Redis или из ini
func (s *Server) getPlaylistView(code string) (*PlaylistView, bool) {
// сначала пробуем in-memory кеш (результаты фоновой проверки)
s.memMu.RLock()
if pls, ok := s.memCache[code]; ok {
s.memMu.RUnlock()
view := &PlaylistView{Playlist: pls, IsKnown: true}
enrichPlaylist(view)
return view, true
}
s.memMu.RUnlock()
// затем пробуем Redis
if view, ok := s.getPlaylistFromCache(code); ok {
return view, true
}
// если нет в кеше — берём метаданные из ini
ini, err := s.loadIni()
if err != nil {
return nil, false
}
iniPls, exists := ini.Lists[code]
if !exists {
return nil, false
}
view := &PlaylistView{
Playlist: playlist.Playlist{
Code: code,
Name: iniPls.Name,
Description: iniPls.Description,
Url: iniPls.Url,
Source: iniPls.Source,
IsOnline: false,
Attributes: make(map[string]string),
Groups: make(map[string]playlist.Group),
Channels: make(map[string]playlist.Channel),
},
IsKnown: false,
Tags: []string{},
ViewChannels: make(map[string]ChannelView),
}
return view, true
}
// getPlaylistsView возвращает все плейлисты для списка (отсортированные по коду)
func (s *Server) getPlaylistsView() ([]PlaylistView, string, error) {
ini, err := s.loadIni()
if err != nil {
return nil, "", err
}
updatedAt := s.getUpdatedAt()
result := make([]PlaylistView, 0, len(ini.Lists))
// собираем коды в порядке из ini-файла
codes := make([]string, 0, len(ini.Lists))
for _, section := range ini.File.Sections() {
if section.Name() == "DEFAULT" {
continue
}
if _, ok := ini.Lists[section.Name()]; ok {
codes = append(codes, section.Name())
}
}
// сначала проверяем in-memory кеш (результаты фоновой проверки)
s.memMu.RLock()
memHasData := len(s.memCache) > 0
s.memMu.RUnlock()
if memHasData {
for _, code := range codes {
iniPls := ini.Lists[code]
s.memMu.RLock()
pls, ok := s.memCache[code]
s.memMu.RUnlock()
if ok {
view := PlaylistView{Playlist: pls, IsKnown: true}
enrichPlaylist(&view)
result = append(result, view)
} else {
result = append(result, makeUnknownView(code, iniPls))
}
}
return result, updatedAt, nil
}
// если in-memory пуст — пробуем Redis
if s.cache != nil {
ctx := context.Background()
cached, err := s.cache.MGet(ctx, codes...).Result()
if err == nil {
for i, code := range codes {
iniPls := ini.Lists[code]
if i < len(cached) {
strVal, ok := cached[i].(string)
if ok && strVal != "" {
var pls playlist.Playlist
if json.Unmarshal([]byte(strVal), &pls) == nil {
view := PlaylistView{Playlist: pls, IsKnown: true}
enrichPlaylist(&view)
result = append(result, view)
continue
}
}
}
result = append(result, makeUnknownView(code, iniPls))
}
return result, updatedAt, nil
}
}
// без кеша — все плейлисты unknown
for _, code := range codes {
result = append(result, makeUnknownView(code, ini.Lists[code]))
}
return result, updatedAt, nil
}
// makeUnknownView создаёт представление непроверенного плейлиста
func makeUnknownView(code string, iniPls playlist.Playlist) PlaylistView {
return PlaylistView{
Playlist: playlist.Playlist{
Code: code,
Name: iniPls.Name,
Description: iniPls.Description,
Url: iniPls.Url,
Source: iniPls.Source,
IsOnline: false,
Attributes: make(map[string]string),
Groups: make(map[string]playlist.Group),
Channels: make(map[string]playlist.Channel),
},
IsKnown: false,
Tags: []string{},
ViewChannels: make(map[string]ChannelView),
}
}
// statusClass возвращает CSS-класс для статуса плейлиста
func statusClass(view *PlaylistView) string {
if !view.IsKnown {
return "secondary"
}
if view.IsOnline {
return "success"
}
return "danger"
}
// statusBadge возвращает текст бейджа статуса
func statusBadge(view *PlaylistView) string {
if !view.IsKnown {
return "unknown"
}
if view.IsOnline {
return "online"
}
return "offline"
}
// formatDate форматирует unix timestamp в строку
func formatDate(ts int64) string {
if ts == 0 {
return ""
}
return time.Unix(ts, 0).Format("02.01.2006 15:04:05")
}
+142
View File
@@ -0,0 +1,142 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="ru" class="h-100">
<head>
<title>{{block "title" .}}{{.Title}}{{end}}</title>
<meta charset="utf-8">
<meta name="description" content="{{block "metadescription" .}}{{end}}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{{block "metakeywords" .}}{{end}}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
{{block "head" .}}{{end}}
</head>
<body class="d-flex flex-column h-100 bg-dark text-light">
<header class="sticky-top bg-dark border-bottom border-secondary">
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
<span>{{.Title}}</span>
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{{range .Navigation}}
{{if .Children}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>&nbsp;{{end}}{{.Title}}
</a>
<ul class="dropdown-menu dropdown-menu-dark">
{{range .Children}}
<li>
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>&nbsp;{{end}}{{.Title}}
</a>
</li>
{{end}}
</ul>
</li>
{{else}}
<li class="nav-item">
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon>&nbsp;{{end}}{{.Title}}
</a>
</li>
{{end}}
{{end}}
</ul>
</div>
</nav>
</header>
<main class="flex-grow-1 container py-4">
{{block "header" .}}{{end}}
<div class="content-wrapper">
{{block "content" .}}{{end}}
</div>
</main>
<footer class="bg-dark border-top border-secondary py-4">
<div class="container text-center">
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
{{range .FooterLinks}}
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon>&nbsp;{{end}}{{.Title}}
</a>
{{end}}
</div>
<div class="small text-secondary d-inline-flex align-items-center gap-1">
<a class="text-secondary" href="{{.RepoUrl}}/releases/tag/v{{.Version}}" target="_blank">
<ion-icon name="pricetag-outline"></ion-icon>&nbsp;iptvc&nbsp;v{{.Version}}
</a>&nbsp;Антон Аксенов &copy; 2025-2026 MIT&nbsp;License
</div>
</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
<div class="d-flex">
<div class="toast-body" id="clipboardToastBody"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
{{block "footer" .}}{{end}}
<script>
function showToast(message) {
const toastEl = document.getElementById('clipboardToast');
const toastBodyEl = document.getElementById('clipboardToastBody');
toastBodyEl.innerHTML = message;
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
toast.show();
}
function copyPlaylistUrl(code) {
const url = '{{.BaseUrl}}/' + code;
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(url)
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
.catch(err => console.error('Failed to copy:', err));
} else {
try {
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
} else {
showToast('Ошибка при копировании ссылки', true);
}
} catch (err) {
console.error('Fallback copy failed:', err);
showToast('Ошибка при копировании ссылки', true);
}
}
}
</script>
</body>
</html>
{{end}}
+493
View File
@@ -0,0 +1,493 @@
{{define "title"}}[{{.Playlist.Code}}] {{.Playlist.Name}} - {{.Title}}{{end}}
{{define "metadescription"}}Смотреть бесплатный самообновляемый плейлист {{.Playlist.Name}}, проверить статус, {{.Playlist.Description}}{{end}}
{{define "head"}}
<style>
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
tr.chrow td{padding:3px}
td.chindex{width:1%}
td.chlogo{width:100px}
div.chlist-table{max-height:550px}
textarea.m3u-raw{font-size:.7rem}
</style>
<script>
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
</script>
{{end}}
{{define "header"}}
<h2>О плейлисте: {{.Playlist.Name}}</h2>
{{end}}
{{define "content"}}
{{$pls := .Playlist}}
<div class="row">
<div class="col-lg-7">
<ul class="nav nav-tabs">
<li class="nav-item small">
<a class="nav-link active"
type="button"
href="#tab-data"
data-bs-toggle="tab"
data-bs-target="#tab-data"
>
<ion-icon name="radio-outline"></ion-icon>&nbsp;Основные&nbsp;данные
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-raw"
data-bs-toggle="tab"
data-bs-target="#tab-raw"
>
<ion-icon name="document-text-outline"></ion-icon>&nbsp;Исходный&nbsp;текст
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-abuse"
data-bs-toggle="tab"
data-bs-target="#tab-abuse"
>
<ion-icon name="wallet-outline"></ion-icon>&nbsp;Правообладателям
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
<table class="table table-dark table-hover small mb-lg-5">
<tbody>
<tr>
<th class="w-25" scope="row">Код</th>
<th class="text-break">
<span class="pe-3 font-monospace">{{$pls.Code}}</span>
{{if not $pls.IsKnown}}
<span class="cursor-help badge small text-dark bg-secondary"
title="Не проверялся"
>unknown</span>
{{else if $pls.IsOnline}}
<span class="cursor-help badge small text-dark bg-success"
title="Вероятно, работает"
>online</span>
{{else}}
<span class="cursor-help badge small text-dark bg-danger"
title="Вероятно, не работает"
>offline</span>
{{end}}
</th>
</tr>
<tr>
<th scope="row">Описание</th>
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
</tr>
<tr>
<th scope="row">Короткая ссылка</th>
<td>
<span onclick="copyPlaylistUrl('{{$pls.Code}}')"
class="cursor-pointer"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
>
<b class="cursor-pointer font-monospace text-break">{{baseUrl $pls.Code}}</b>
<ion-icon name="copy-outline"></ion-icon>
</span>
</td>
</tr>
<tr>
<th scope="row">Источник</th>
<td class="text-break">{{$pls.Source}}</td>
</tr>
<tr>
<th scope="row">Наполнение</th>
<td class="text-break">
{{if and $pls.IsKnown $pls.IsOnline}}
{{if $pls.HasTokens}}
<span class="cursor-help badge bg-info text-dark">
<ion-icon name="paw"></ion-icon>
</span>&nbsp;могут быть нестабильные каналы<br>
{{end}}
{{if hasTag $pls.Tags "adult"}}
<span class="cursor-help badge small bg-warning text-dark">18+</span>&nbsp;есть каналы для взрослых<br>
{{end}}
<ion-icon name="folder-open-outline"></ion-icon>&nbsp;группы: {{groupCount $pls}}<br>
<ion-icon name="videocam-outline"></ion-icon>&nbsp;каналы:
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
{{$pls.OnlineCount}} ({{$pls.OnlinePercent}}%)
</span>
+
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
{{$pls.OfflineCount}} ({{$pls.OfflinePercent}}%)
</span>
= {{channelCount $pls}}
{{end}}
</td>
</tr>
<tr>
<th scope="row">Возможности</th>
<td class="text-break">
{{if and $pls.IsKnown $pls.IsOnline}}
<ion-icon name="newspaper-outline"></ion-icon>&nbsp;Программа передач:&nbsp;{{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
<ion-icon name="play-back"></ion-icon>&nbsp;Перемотка&nbsp;(архив):&nbsp;{{if $pls.HasCatchup}}есть{{else}}нет{{end}}
{{end}}
</td>
</tr>
<tr class="text-secondary">
<th scope="row">M3U</th>
<td class="text-break">{{$pls.Url}}</td>
</tr>
<tr class="text-secondary">
<th class="w-25" scope="row">Проверка плейлиста</th>
<td class="text-break">
<span title="Фактическая метка времени окончания проверки плейлиста">
{{formatDate $pls.CheckedAt}}
</span>
</td>
</tr>
{{if and $pls.IsKnown (not $pls.IsOnline)}}
<tr class="text-secondary">
<th class="w-25" scope="row">Ошибка проверки</th>
<td class="text-break">{{$pls.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
{{if $pls.Attributes}}
<h4>Дополнительные атрибуты</h4>
<table class="table table-dark table-hover small font-monospace">
<tbody>
{{range $attr, $val := $pls.Attributes}}
<tr>
<th class="w-25" scope="row">{{$attr}}</th>
<td class="text-break">{{$val}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div class="tab-pane fade" id="tab-raw" tabindex="1">
<button class="btn btn-sm btn-success my-3"
id="saveM3UBtn"
onclick="savePlaylist()"
>
<ion-icon name="download-outline"></ion-icon>&nbsp;{{$pls.Code}}.m3u8
</button>
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
rows="40"
id="m3u-raw"
readonly
>{{$pls.Content}}</textarea>
</div>
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
<p class="my-3">
Данные, представленные на данной странице, получены автоматически из открыто доступных в
интернете IPTV-плейлистов, опубликованных третьими лицами.
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
данные".
</p>
<p class="my-3">
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
телепрограмм.
</p>
<p class="my-3">
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
формируется исключительно путём обработки содержимого самого плейлиста.
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
</p>
<p class="my-3">
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
</p>
</div>
</div>
</div>
<div class="col-lg-5">
<h4>Список каналов:&nbsp;<span id="chcount">{{channelCount $pls}}</span></h4>
{{if gt (channelCount $pls) 0}}
{{if gt (groupCount $pls) 1}}
<div class="row my-3">
<div class="col-12">
{{if gt (channelCount $pls) 100}}
<div class="alert alert-warning small" role="alert" id="chListLoading">
<div class="spinner-border text-success spinner-border-sm" role="status"></div>
Загрузка...
</div>
{{end}}
<div class="input-group">
<select id="groupSelector"
class="form-select form-select-sm border-secondary bg-dark text-light"
onchange="updateFilter()"
>
<option selected value="all">Все группы</option>
{{range $pls.Groups}}
<option value="{{.Id}}">{{.Name}}</option>
{{end}}
</select>
<button type="button"
onclick="resetGroup()"
class="btn btn-sm btn-outline-secondary"
title="Сбросить группу"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
</div>
</div>
{{end}}
<div class="row my-3">
<div class="col-12">
<div class="input-group">
<input type="text"
id="search-field"
class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
placeholder="Поиск каналов..."
title="Начни вводить название"
/>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfAll"
autocomplete="off"
onclick="updateFilter()"
checked
>
<label class="btn btn-sm btn-outline-secondary"
for="chfAll"
title="Выбрать все каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOnline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-success"
for="chfOnline"
title="Выбрать только онлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OnlineCount}}
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOffline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-danger"
for="chfOffline"
title="Выбрать только оффлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OfflineCount}}
</label>
<button type="button"
class="btn btn-sm btn-outline-secondary"
onclick="resetSearch()"
title="Сбросить фильтрацию"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<div class="my-3">
{{range $pls.Tags}}
<input type="checkbox"
class="btn-check"
id="btn-tag-{{.}}"
data-tag="{{.}}"
autocomplete="off"
onclick="updateFilter()"
>
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
for="btn-tag-{{.}}"
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
>#{{.}}</label>
{{end}}
</div>
</div>
</div>
<div class="chlist-table overflow-auto">
<table id="chlist" class="table table-dark table-hover small">
<tbody class="list">
</tbody>
</table>
</div>
{{else if and $pls.IsKnown (not $pls.IsOnline)}}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{$pls.Content}}
</div>
{{else if not $pls.IsKnown}}
<div class="alert alert-warning small" role="alert" id="chListLoading">
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
<br><br>
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
</div>
{{end}}
</div>
</div>
{{end}}
{{define "footer"}}
{{if and .Playlist.IsKnown .Playlist.IsOnline}}
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js"></script>
<script>
function getChannelTemplate(channel) {
const httpCode = channel.status ?? '(неизвестно)'
const errText = !!channel.error
? channel.error.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
: '(нет)'
const logoUrl = channel.attributes['tvg-logo'] ?? DEFAULT_LOGO
const logoHint = !!channel.attributes['tvg-logo']
? `Логотип канала '${channel.title}'`
: `Нет логотипа для канала '${channel.title}'`
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
const statusIconHint = channel.isOnline
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
const adultIcon = channel.tags.indexOf('adult') !== -1
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
: ''
const pawIcon = channel.hasToken
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
: ''
const tvgId = !!channel.attributes['tvg-id']
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon>&nbsp;${channel.attributes['tvg-id']}</div>`
: ``
const mimeType = !!channel.contentType
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon>&nbsp;${channel.contentType}</div>`
: ``
const tags = channel.tags.length > 0
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon>&nbsp;`
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
: ``
return `<tr class="chrow" title="&#010;HTTP: ${httpCode}&#010;Error: ${errText}">
<td class="chlogo text-center">
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
</td>
<td class="text-break">
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
${adultIcon}
${pawIcon}
${channel.title}
<div class="text-secondary small">
${tvgId}
${mimeType}
${tags}
</div>
</td>
</tr>`
}
const options = {
searchColumns: ['title'],
item: (channel) => getChannelTemplate(channel),
};
const values = {{jsonChannels .Playlist.ViewChannels}}
const list = new List('chlist', options, values)
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
document.addEventListener("DOMContentLoaded", () => {
const alert = document.getElementById("chListLoading")
!!alert && alert.remove()
});
function savePlaylist() {
const link = document.createElement("a");
const content = document.getElementById("m3u-raw").value
const file = new Blob([content], { type: 'text/plain' });
link.href = URL.createObjectURL(file);
link.download = "{{.Playlist.Code}}.m3u8";
link.click();
URL.revokeObjectURL(link.href);
}
function resetGroup() {
document.getElementById('groupSelector').value = 'all'
updateFilter()
}
function resetSearch() {
list.search('')
document.getElementById('search-field').value = ''
document.getElementById('chfAll').checked = true
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
updateFilter()
}
function updateFilter() {
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
const tagsSelected = []
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
switch (activeType) {
case 'chfAll':
list.filter(item => {
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && hasValidTags;
})
break
case 'chfOnline':
list.filter(item => {
const isOnline = item.values().isOnline
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOnline && hasValidTags
})
break
case 'chfOffline':
list.filter(item => {
const isOffline = !item.values().isOnline
const chTags = item.values().tags
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOffline && hasValidTags
})
break
}
}
</script>
{{end}}
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "title"}}{{.Title}}{{end}}
{{define "metadescription"}}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{{end}}
{{define "metakeywords"}}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{{end}}
{{define "head"}}
<style>
.card {transition: box-shadow .2s, transform .2s}
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
</style>
<script>
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
</script>
{{end}}
{{define "header"}}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<div class="mb-2">
<h2 class="mb-0">Список плейлистов ({{.Count}})</h2>
<div class="text-muted small">Изменён {{.UpdatedAt}} МСК</div>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-danger" role="alert">
<ion-icon name="alert-circle-outline" class="me-1"></ion-icon>{{.Error}}
</div>
{{else if eq (len .Playlists) 0}}
<div class="alert alert-warning" role="alert">
<ion-icon name="information-circle-outline" class="me-1"></ion-icon>Список плейлистов пуст.
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
</div>
{{end}}
<div class="row g-4">
{{range .Playlists}}
{{$sc := statusClass .}}
<div class="col-md-6 col-lg-4">
<div class="card bg-dark text-light h-100 border border-{{$sc}} hover-{{$sc}} position-relative">
<a href="/{{.Code}}/details" class="text-decoration-none">
<div class="card-header d-flex align-items-center gap-2">
<span class="font-monospace text-{{$sc}}">{{.Code}}</span>
{{if not .IsKnown}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Плейлист ещё не проверялся, придётся подождать"
>unknown</span>
{{else if .IsOnline}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Возможно, этот плейлист рабочий"
>online</span>
{{else}}
<span class="cursor-help badge bg-{{$sc}} ms-auto"
title="Этот плейлист нерабочий или его не удалось проверить"
>offline</span>
{{end}}
{{if and .IsKnown .IsOnline}}
<span class="cursor-help badge border border-success"
title="Процент рабочих каналов"
>{{.OnlinePercent}}%</span>
{{end}}
{{if hasTag .Tags "adult"}}
<span class="cursor-help badge bg-warning text-dark"
title="Есть каналы для взрослых!"
>18+</span>
{{end}}
{{if .HasTokens}}
<span class="cursor-help badge bg-info text-dark"
title="В плейлисте есть каналы, которые могут быть нестабильны"
><ion-icon name="paw"></ion-icon></span>
{{end}}
</div>
</a>
<div class="card-body position-relative z-2">
<a href="/{{.Code}}/details" class="text-decoration-none">
<h5 class="card-title text-light">{{.Name}}</h5>
</a>
{{if .Description}}
<p class="card-text small text-secondary d-none d-md-block">{{.Description}}</p>
{{end}}
<div class="d-flex flex-wrap gap-2 mb-1">
{{if and .IsKnown .IsOnline}}
<span class="badge border border-secondary">
<ion-icon name="videocam-outline" class="me-1"></ion-icon>&nbsp;{{channelCount .}}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{{if gt (groupCount .) 0}}
<span class="badge border border-secondary">
<ion-icon name="folder-open-outline" class="me-1"></ion-icon>&nbsp;{{groupCount .}}<span class="d-none d-xl-inline-block">&nbsp;групп</span>
</span>
{{end}}
{{if .HasTvg}}
<span class="badge border border-secondary">
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{{end}}
{{if .HasCatchup}}
<span class="badge border border-secondary">
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block">&nbsp;Архив</span>
</span>
{{end}}
{{end}}
</div>
</div>
<div class="card-footer cursor-pointer"
onclick="copyPlaylistUrl('{{.Code}}')"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
>
<div class="d-flex justify-content-between align-items-center small">
<span class="font-monospace text-truncate">
{{baseUrl .Code}}
</span>
<ion-icon name="copy-outline"></ion-icon>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{if gt .PageCount 1}}
<nav class="mt-4">
<ul class="pagination justify-content-center">
{{range $page := seq 1 .PageCount}}
{{if eq $page $.PageCurrent}}
<li class="page-item active" aria-current="page">
<span class="page-link">{{$page}}</span>
</li>
{{else}}
<li class="page-item">
<a class="page-link bg-dark text-light" href="/page/{{$page}}">{{$page}}</a>
</li>
{{end}}
{{end}}
</ul>
</nav>
{{end}}
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "title"}}Плейлист не найден - {{.Title}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6 text-center">
<div class="card bg-dark border-secondary">
<div class="card-body">
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
<h2 class="card-title">Плейлист <code>{{.Code}}</code> не найден</h2>
<p class="card-text">
Возможно, его здесь никогда не было, либо он уже был удалён.
</p>
<p class="text-muted small">
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
</p>
<a class="btn btn-outline-light" href="/" title="На главную">
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
</a>
</div>
</div>
</div>
</div>
{{end}}
+360
View File
@@ -0,0 +1,360 @@
package web
import (
"testing"
"axenov/iptv-checker/app/playlist"
)
func TestHasTokensString(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"http://example.com/stream.m3u8?token=abc", true},
{"http://example.com/stream?user=test&pwd=secret", true},
{"http://example.com/stream?drmreq=xyz", true},
{"http://example.com/stream?u=foo", true},
{"http://example.com/stream?password=123", true},
{"http://example.com/stream.m3u8", false},
{"", false},
{"plain text without tokens", false},
}
for _, c := range cases {
got := hasTokensString(c.in)
if got != c.want {
t.Errorf("hasTokensString(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestStatusClass(t *testing.T) {
cases := []struct {
view *PlaylistView
want string
}{
{&PlaylistView{IsKnown: false}, "secondary"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "success"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "danger"},
}
for _, c := range cases {
got := statusClass(c.view)
if got != c.want {
t.Errorf("statusClass() = %s, want %s", got, c.want)
}
}
}
func TestStatusBadge(t *testing.T) {
cases := []struct {
view *PlaylistView
want string
}{
{&PlaylistView{IsKnown: false}, "unknown"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "online"},
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "offline"},
}
for _, c := range cases {
got := statusBadge(c.view)
if got != c.want {
t.Errorf("statusBadge() = %s, want %s", got, c.want)
}
}
}
func TestFormatDate(t *testing.T) {
if formatDate(0) != "" {
t.Error("expected empty string for ts=0")
}
result := formatDate(1700000000)
if result == "" {
t.Error("expected non-empty date string")
}
}
func TestIsAlphanumeric(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"ABC123", true},
{"abc", true},
{"123", true},
{"", false},
{"ABC-123", false},
{"ABC_123", false},
{"ABC 123", false},
{"Привет", false},
}
for _, c := range cases {
got := isAlphanumeric(c.in)
if got != c.want {
t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestParseRedisVersion(t *testing.T) {
info := "# Server\nredis_version:6.2.6\nredis_mode:standalone\n"
got := parseRedisVersion(info)
if got != "6.2.6" {
t.Errorf("expected '6.2.6', got '%s'", got)
}
}
func TestParseRedisVersion_NotFound(t *testing.T) {
info := "# Server\nsome_other_field:foo\n"
got := parseRedisVersion(info)
if got != "unknown" {
t.Errorf("expected 'unknown', got '%s'", got)
}
}
func TestContainsTag(t *testing.T) {
tags := []string{"hd", "adult", "russian"}
if !containsTag(tags, "adult") {
t.Error("expected true for 'adult'")
}
if containsTag(tags, "movies") {
t.Error("expected false for 'movies'")
}
if containsTag([]string{}, "x") {
t.Error("expected false for empty list")
}
}
func TestMakeUnknownView(t *testing.T) {
iniPls := playlist.Playlist{
Name: "Test",
Description: "Desc",
Url: "http://example.com",
Source: "public",
}
view := makeUnknownView("CODE", iniPls)
if view.Code != "CODE" {
t.Errorf("expected Code='CODE', got '%s'", view.Code)
}
if view.IsKnown {
t.Error("expected IsKnown=false")
}
if view.Name != "Test" {
t.Errorf("expected Name='Test', got '%s'", view.Name)
}
if len(view.Tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(view.Tags))
}
}
func TestEnrichPlaylist_OnlinePercent(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
IsOnline: true,
OnlineCount: 8,
OfflineCount: 2,
Channels: map[string]playlist.Channel{
"1": {},
"2": {},
"3": {},
"4": {},
"5": {},
"6": {},
"7": {},
"8": {},
"9": {},
"10": {},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "#EXTM3U",
},
IsKnown: true,
}
enrichPlaylist(view)
if view.OnlinePercent != 80 {
t.Errorf("expected 80%%, got %d%%", view.OnlinePercent)
}
if view.OfflinePercent != 20 {
t.Errorf("expected 20%%, got %d%%", view.OfflinePercent)
}
}
func TestEnrichPlaylist_Unknown(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{"1": {}},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: false,
}
enrichPlaylist(view)
if view.OnlinePercent != 0 || view.OfflinePercent != 0 {
t.Errorf("expected 0%% for unknown, got online=%d%% offline=%d%%",
view.OnlinePercent, view.OfflinePercent)
}
}
func TestEnrichPlaylist_HasCatchup(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Content: "#EXTM3U catchup=default",
Channels: map[string]playlist.Channel{},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasCatchup {
t.Error("expected HasCatchup=true")
}
}
func TestEnrichPlaylist_HasTvg(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
Channels: map[string]playlist.Channel{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasTvg {
t.Error("expected HasTvg=true")
}
}
func TestEnrichPlaylist_HasTokens(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Url: "http://example.com/list.m3u8?token=abc",
Content: "#EXTM3U",
Channels: map[string]playlist.Channel{},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
},
IsKnown: true,
}
enrichPlaylist(view)
if !view.HasTokens {
t.Error("expected HasTokens=true")
}
}
func TestEnrichPlaylist_TagsCollection(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{
"1": {Tags: []string{"hd", "russian"}},
"2": {Tags: []string{"adult", "hd"}},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
if len(view.Tags) != 3 {
t.Errorf("expected 3 unique tags, got %d: %v", len(view.Tags), view.Tags)
}
}
func TestEnrichPlaylist_ChannelHasToken(t *testing.T) {
view := &PlaylistView{
Playlist: playlist.Playlist{
Channels: map[string]playlist.Channel{
"1": {URL: "http://example.com/stream?token=abc"},
},
Attributes: map[string]string{},
Groups: map[string]playlist.Group{},
Content: "",
},
IsKnown: true,
}
enrichPlaylist(view)
for _, cv := range view.ViewChannels {
if !cv.HasToken {
t.Error("expected channel HasToken=true")
}
}
}
func TestComputeStats(t *testing.T) {
playlists := []PlaylistView{
{
Playlist: playlist.Playlist{
Code: "RU",
IsOnline: true,
CheckedAt: 1700000000,
Channels: map[string]playlist.Channel{"1": {IsOnline: true}, "2": {IsOnline: false}},
Groups: map[string]playlist.Group{"g1": {}},
Attributes: map[string]string{"url-tvg": "http://epg.xml"},
Content: "#EXTM3U catchup=default",
},
IsKnown: true,
HasCatchup: true,
HasTvg: true,
Tags: []string{"adult"},
},
{
Playlist: playlist.Playlist{
Code: "EN",
IsOnline: false,
CheckedAt: 1700000100,
Channels: map[string]playlist.Channel{"1": {IsOnline: false}},
},
IsKnown: false,
},
}
stats := computeStats(playlists)
plsStats := stats["playlists"].(map[string]interface{})
if plsStats["all"].(int) != 2 {
t.Errorf("expected 2 playlists, got %d", plsStats["all"])
}
if plsStats["online"].(int) != 1 {
t.Errorf("expected 1 online, got %d", plsStats["online"])
}
if plsStats["unknown"].(int) != 1 {
t.Errorf("expected 1 unknown, got %d", plsStats["unknown"])
}
if plsStats["adult"].(int) != 1 {
t.Errorf("expected 1 adult, got %d", plsStats["adult"])
}
if plsStats["hasCatchup"].(int) != 1 {
t.Errorf("expected 1 catchup, got %d", plsStats["hasCatchup"])
}
if plsStats["hasTvg"].(int) != 1 {
t.Errorf("expected 1 tvg, got %d", plsStats["hasTvg"])
}
if plsStats["groupped"].(int) != 1 {
t.Errorf("expected 1 groupped, got %d", plsStats["groupped"])
}
chStats := stats["channels"].(map[string]interface{})
if chStats["all"].(int) != 3 {
t.Errorf("expected 3 channels, got %d", chStats["all"])
}
if chStats["online"].(int) != 1 {
t.Errorf("expected 1 online channel, got %d", chStats["online"])
}
if chStats["offline"].(int) != 2 {
t.Errorf("expected 2 offline channels, got %d", chStats["offline"])
}
latest := plsStats["latest"].(map[string]interface{})
if latest["code"] != "EN" {
t.Errorf("expected latest code 'EN', got '%v'", latest["code"])
}
}
func TestComputeStats_Empty(t *testing.T) {
stats := computeStats([]PlaylistView{})
plsStats := stats["playlists"].(map[string]interface{})
if plsStats["all"].(int) != 0 {
t.Errorf("expected 0, got %d", plsStats["all"])
}
}