This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit 1cbf3b0e19
31 changed files with 5034 additions and 157 deletions
+444
View File
@@ -0,0 +1,444 @@
/*
* Copyright (c) 2025, Антон Аксенов
* 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"
"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)
}
// 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
}
// если онлайн — не отдаем content
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": view.Attributes,
"groups": view.Groups,
"channels": view.Channels,
"tags": view.Tags,
}
if view.IsKnown {
resp["isOnline"] = view.IsOnline
if view.IsOnline {
delete(resp, "content")
} else {
resp["content"] = view.Content
}
}
s.jsonResponse(w, http.StatusOK, resp)
}
// 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
}
+204
View File
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2025, Антон Аксенов
* 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/{code}", s.handleAPIGetOne)
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
// 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 {
Every uint
Repeat uint
Random uint
Files []string
Urls []string
Codes []string
}
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
if opts.Every == 0 {
opts.Every = 60
}
// каждый проверенный плейлист сразу попадает в in-memory кеш
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
s.memMu.Lock()
s.memCache[pls.Code] = pls
s.memMu.Unlock()
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
s.iniLoaded = time.Time{}
}
log.Printf("Background checker started: every=%ds repeat=%d", opts.Every, 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... seconds=%d", opts.Every)
time.Sleep(time.Duration(opts.Every) * time.Second)
}
}
// 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(),
)
}
+176
View File
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025, Антон Аксенов
* 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, Антон Аксенов
* 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")
}
+145
View File
@@ -0,0 +1,145 @@
{{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>{{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>{{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>{{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>{{end}}&nbsp;{{.Title}}
</a>
{{end}}
</div>
<div>
<a class="small text-secondary d-inline-flex align-items-center gap-1"
href="{{.RepoUrl}}/releases/tag/v{{.Version}}"
target="_blank"
>
<ion-icon name="pricetag-outline"></ion-icon>iptvc&nbsp;v{{.Version}}
</a>
</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"])
}
}