wip1
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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}} {{.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 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}}
|
||||
@@ -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> Основные данные
|
||||
</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> Исходный текст
|
||||
</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> Правообладателям
|
||||
</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> могут быть нестабильные каналы<br>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag $pls.Tags "adult"}}
|
||||
<span class="cursor-help badge small bg-warning text-dark">18+</span> есть каналы для взрослых<br>
|
||||
{{end}}
|
||||
|
||||
<ion-icon name="folder-open-outline"></ion-icon> группы: {{groupCount $pls}}<br>
|
||||
<ion-icon name="videocam-outline"></ion-icon> каналы:
|
||||
<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> Программа передач: {{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
|
||||
<ion-icon name="play-back"></ion-icon> Перемотка (архив): {{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> {{$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>Список каналов: <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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
: '(нет)'
|
||||
|
||||
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> ${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> ${channel.contentType}</div>`
|
||||
: ``
|
||||
|
||||
const tags = channel.tags.length > 0
|
||||
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon> `
|
||||
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
|
||||
: ``
|
||||
|
||||
return `<tr class="chrow" title="
HTTP: ${httpCode}
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}}
|
||||
@@ -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> {{channelCount .}}<span class="d-none d-xl-inline-block"> каналов</span>
|
||||
</span>
|
||||
{{if gt (groupCount .) 0}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="folder-open-outline" class="me-1"></ion-icon> {{groupCount .}}<span class="d-none d-xl-inline-block"> групп</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"> ТВ-программа</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"> Архив</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}}
|
||||
@@ -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}}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user