This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit cd2ab11c44
65 changed files with 9901 additions and 413 deletions
+255
View File
@@ -0,0 +1,255 @@
package web
import (
"axenov/iptv-checker/app/config"
"axenov/iptv-checker/app/playlist"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// makeTestServer создаёт HTTP-сервер с тестовыми плейлистами в memCache.
// Возвращает сервер и обёртку для добавления плейлистов в memCache.
func makeTestServer(t *testing.T) (*httptest.Server, *Server) {
t.Helper()
cfg := config.Init("")
cfg.App.Playlists = "testdata/playlists.ini"
srv := NewServer(cfg, nil)
mux := http.NewServeMux()
// API routes
mux.HandleFunc("GET /api/playlists", srv.handleAPIGetAll)
mux.HandleFunc("GET /api/playlists/{code}", srv.handleAPIGetOne)
mux.HandleFunc("GET /api/playlists/{code}/channels", srv.handleAPIGetChannels)
mux.HandleFunc("GET /api/version", srv.handleAPIVersion)
mux.HandleFunc("GET /api/health", srv.handleAPIHealth)
mux.HandleFunc("GET /api/stats", srv.handleAPIStats)
mux.HandleFunc("GET /api/openapi.json", srv.handleOpenAPISpec)
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
})
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
// page routes
mux.HandleFunc("GET /{$}", srv.handleHome)
mux.HandleFunc("GET /{path...}", srv.handleRoute)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts, srv
}
// putPlaylist кладёт плейлист в in-memory кеш сервера
func (s *Server) putPlaylist(pls playlist.Playlist) {
s.memMu.Lock()
defer s.memMu.Unlock()
if s.memCache == nil {
s.memCache = make(map[string]playlist.Playlist)
}
s.memCache[pls.Code] = pls
}
// makeOnlinePlaylist создаёт тестовый плейлист с онлайн/оффлайн каналами
func makeOnlinePlaylist() playlist.Playlist {
return playlist.Playlist{
Code: "test1",
Name: "Test Playlist 1",
Description: "A test playlist",
Url: "http://example.com/test1.m3u8",
Source: "test source",
IsOnline: true,
CheckedAt: 1700000000,
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
Groups: map[string]playlist.Group{
"group1": {Id: "group1", Name: "Group One"},
"group2": {Id: "group2", Name: "Group Two"},
},
Channels: map[string]playlist.Channel{
"id1": {
Id: "id1",
Title: "Channel One",
URL: "http://example.com/stream1.m3u8",
GroupId: "group1",
Attributes: map[string]string{"tvg-id": "ch1", "tvg-logo": "http://example.com/logo1.png"},
Status: 200,
IsOnline: true,
Tags: []string{"hd"},
},
"id2": {
Id: "id2",
Title: "Channel Two",
URL: "http://example.com/stream2.m3u8",
GroupId: "group2",
Attributes: map[string]string{"tvg-id": "ch2", "tvg-logo": "http://example.com/logo2.png"},
Status: 404,
IsOnline: false,
Tags: []string{"untagged"},
},
"id3": {
Id: "id3",
Title: "Channel Three",
URL: "http://example.com/stream3.m3u8",
GroupId: "group1",
Attributes: map[string]string{"tvg-id": "ch3", "tvg-logo": "http://example.com/logo3.png"},
Status: 200,
IsOnline: true,
Tags: []string{"hd", "news"},
},
},
OnlineCount: 2,
OfflineCount: 1,
}
}
// fetchBody читает тело HTTP-ответа целиком и возвращает его как строку
func fetchBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %s", err)
}
return string(b)
}
// TestDetailsPageRendersChannelsTable проверяет, что на странице деталей
// онлайн-плейлиста присутствует таблица с каналами и данными для list.js
func TestDetailsPageRendersChannelsTable(t *testing.T) {
ts, srv := makeTestServer(t)
srv.putPlaylist(makeOnlinePlaylist())
// Главная страница должна быть доступна
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET /: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("GET /: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Страница деталей — основной сценарий
resp, err = http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /test1/details: expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
checks := []struct {
name string
want bool
}{
// Заголовок и базовая структура
{"заголовок 'Список каналов'", strings.Contains(body, "Список каналов")},
{"id таблицы 'chlist'", strings.Contains(body, `id="chlist"`)},
{"класс 'list' у tbody", strings.Contains(body, `class="list"`)},
{"chcount = 3", strings.Contains(body, `<span id="chcount">3</span>`)},
// Данные каналов в JSON
{"values JSON", strings.Contains(body, "const values =")},
{"Channel One в JSON", strings.Contains(body, "Channel One")},
{"Channel Two в JSON", strings.Contains(body, "Channel Two")},
{"Channel Three в JSON", strings.Contains(body, "Channel Three")},
// Группы (отображаются когда > 1)
{"id группы group1", strings.Contains(body, `value="group1"`)},
{"id группы group2", strings.Contains(body, `value="group2"`)},
// Теги
{"тег #hd", strings.Contains(body, "btn-tag-hd")},
{"тег #news", strings.Contains(body, "btn-tag-news")},
{"тег #untagged", strings.Contains(body, "btn-tag-untagged")},
// list.js
{"list.js подключён", strings.Contains(body, "list.js@2.3.1")},
{"getChannelTemplate функция", strings.Contains(body, "function getChannelTemplate")},
{"опции list.js", strings.Contains(body, "const options =")},
{"new List('chlist')", strings.Contains(body, "new List('chlist'")},
{"getChannelTemplate возвращает <tr>", strings.Contains(body, "chrow")},
// Счётчики
{"OnlineCount отображается (~66%)", strings.Contains(body, "2 (66%)") || strings.Contains(body, "2 (67%)")},
{"OfflineCount отображается (~33%)", strings.Contains(body, "1 (33%)") || strings.Contains(body, "1 (34%)")},
}
for _, c := range checks {
if !c.want {
t.Errorf("page missing: %s", c.name)
}
}
}
// TestDetailsPageOfflinePlaylist проверяет, что для оффлайн-плейлиста
// (IsKnown=true, IsOnline=false) вместо таблицы каналов показывается
// сообщение об ошибке
func TestDetailsPageOfflinePlaylist(t *testing.T) {
ts, srv := makeTestServer(t)
srv.putPlaylist(playlist.Playlist{
Code: "test1",
Name: "Test Playlist 1",
Url: "http://example.com/test1.m3u8",
IsOnline: false,
Content: "HTTP status 404",
CheckedAt: 1700000000,
})
resp, err := http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "Ошибка плейлиста") {
t.Error("offline playlist should show error message")
}
if !strings.Contains(body, "HTTP status 404") {
t.Error("offline playlist should show error text from Content")
}
if strings.Contains(body, `id="chlist"`) {
t.Error("offline playlist should NOT render channels table")
}
}
// TestDetailsPageUnknownPlaylist проверяет, что для непроверенного плейлиста
// (IsKnown=false) вместо таблицы каналов показывается предупреждение
func TestDetailsPageUnknownPlaylist(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/test1/details")
if err != nil {
t.Fatalf("GET /test1/details: %s", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "Список каналов сейчас неизвестен") {
t.Error("unknown playlist should show 'list unknown' warning")
}
if strings.Contains(body, `id="chlist"`) {
t.Error("unknown playlist should NOT render channels table")
}
}
// TestDetailsPageNonexistentPlaylist проверяет, что несуществующий код
// возвращает 404
func TestDetailsPageNonexistentPlaylist(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/nonexistent/details")
if err != nil {
t.Fatalf("GET /nonexistent/details: %s", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
resp.Body.Close()
}
+535
View File
@@ -0,0 +1,535 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app"
"axenov/iptv-checker/app/playlist"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// handleHome — главная страница со списком плейлистов
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
playlists, updatedAt, err := s.getPlaylistsView()
errorMsg := ""
if err != nil {
log.Printf("Error loading playlists: %s", err)
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
playlists = []PlaylistView{}
updatedAt = ""
}
count := len(playlists)
pageSize := int(s.cfg.Site.PageSize)
pageCurrent := 1
pageCount := 1
// pagination
if pageSize > 0 && count > pageSize {
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
if pageArg := r.PathValue("page"); pageArg != "" {
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
pageCurrent = p
}
}
offset := (pageCurrent - 1) * pageSize
if offset >= count {
offset = 0
pageCurrent = 1
}
end := offset + pageSize
if end > count {
end = count
}
playlists = playlists[offset:end]
}
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
UpdatedAt: updatedAt,
UpdatedAtTZ: s.getTimezoneLabel(),
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
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,
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
Playlist: view,
}
s.templates.Render(w, "details", data)
}
// handleRoute — единый обработчик для не-API маршрутов
// Разбирает путь и направляет на нужный обработчик:
// /page/N → главная с пагинацией
// /CODE/details → страница плейлиста
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
// остальное → 404
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
if path == "" {
s.handleHome(w, r)
return
}
segments := strings.Split(path, "/")
switch len(segments) {
case 1:
// /page/N → пагинация
if segments[0] == "page" {
s.renderNotFound(w, r, "")
return
}
if strings.HasPrefix(segments[0], "page/") {
// shouldn't happen with single segment, but just in case
pageStr := strings.TrimPrefix(segments[0], "page/")
if _, err := strconv.Atoi(pageStr); err == nil {
r.SetPathValue("page", pageStr)
s.handleHome(w, r)
return
}
}
// /CODE or /CODE.m3u[8] → redirect
s.handleRedirect(w, r, segments[0])
case 2:
// /page/N → пагинация
if segments[0] == "page" {
if _, err := strconv.Atoi(segments[1]); err == nil {
r.SetPathValue("page", segments[1])
s.handleHome(w, r)
return
}
s.renderNotFound(w, r, "")
return
}
// /CODE/details → страница плейлиста
if segments[1] == "details" {
r.SetPathValue("code", segments[0])
s.handleDetails(w, r)
return
}
s.renderNotFound(w, r, segments[0])
default:
s.renderNotFound(w, r, "")
}
}
// handleRedirect — перенаправляет на URL плейлиста по коду
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
// strip .m3u / .m3u8 suffix
code := path
code = strings.TrimSuffix(code, ".m3u8")
code = strings.TrimSuffix(code, ".m3u")
// validate: alphanumeric
if !isAlphanumeric(code) {
s.renderNotFound(w, r, code)
return
}
// look up in ini
ini, err := s.loadIni()
if err != nil {
s.renderNotFound(w, r, code)
return
}
pls, exists := ini.Lists[code]
if !exists || pls.Url == "" {
s.renderNotFound(w, r, code)
return
}
http.Redirect(w, r, pls.Url, http.StatusFound)
}
// handleAPIGetAll — API: список плейлистов
func (s *Server) handleAPIGetAll(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
items := make([]map[string]interface{}, 0, len(playlists))
for _, view := range playlists {
items = append(items, buildAPIPlaylist(view))
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": items,
})
}
// handleAPIGetOne — API: информация о плейлисте
func (s *Server) handleAPIGetOne(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
s.jsonResponse(w, http.StatusOK, buildAPIPlaylist(*view))
}
// handleAPIGetChannels — API: каналы плейлиста
func (s *Server) handleAPIGetChannels(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
return
}
view, ok := s.getPlaylistView(code)
if !ok {
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
return
}
channels := make([]map[string]interface{}, 0, len(view.Channels))
for _, ch := range view.ViewChannels {
channels = append(channels, map[string]interface{}{
"id": ch.Id,
"title": ch.Title,
"url": ch.URL,
"groupId": ch.GroupId,
"attributes": mapToAttributeArray(ch.Attributes),
"status": ch.Status,
"isOnline": ch.IsOnline,
"error": ch.Error,
"contentType": ch.ContentType,
"tags": ch.Tags,
"checkedAt": ch.CheckedAt,
"hasToken": ch.HasToken,
})
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"items": channels,
})
}
// buildAPIPlaylist формирует JSON-представление плейлиста для API
func buildAPIPlaylist(view PlaylistView) map[string]interface{} {
resp := map[string]interface{}{
"code": view.Code,
"name": view.Name,
"description": view.Description,
"url": view.Url,
"source": view.Source,
"isOnline": nil,
"onlineCount": view.OnlineCount,
"offlineCount": view.OfflineCount,
"checkedAt": view.CheckedAt,
"attributes": mapToAttributeArray(view.Attributes),
"groups": groupsToArray(view.Groups),
"tags": view.Tags,
}
if view.IsKnown {
resp["isOnline"] = view.IsOnline
if view.IsOnline {
delete(resp, "content")
} else {
resp["content"] = view.Content
}
}
return resp
}
// mapToAttributeArray преобразует map атрибутов в массив {name, value}
func mapToAttributeArray(attrs map[string]string) []map[string]string {
result := make([]map[string]string, 0, len(attrs))
for name, value := range attrs {
result = append(result, map[string]string{
"name": name,
"value": value,
})
}
return result
}
// groupsToArray преобразует map групп в массив объектов
func groupsToArray(groups map[string]playlist.Group) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
result = append(result, map[string]interface{}{
"id": g.Id,
"name": g.Name,
"attributes": mapToAttributeArray(g.Attributes),
})
}
return result
}
// handleAPIVersion — API: версия
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"iptvc": app.VERSION,
}
if s.cache != nil {
ctx := r.Context()
info, err := s.cache.Info(ctx, "server").Result()
if err == nil {
resp["keydb"] = parseRedisVersion(info)
}
}
s.jsonResponse(w, http.StatusOK, resp)
}
// handleAPIHealth — API: состояние
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"timestamp": time.Now().Unix(),
}
if s.cache != nil {
ctx := r.Context()
health["redis"] = map[string]interface{}{
"isConnected": s.cache.Ping(ctx).Err() == nil,
}
} else {
health["redis"] = map[string]interface{}{
"isConnected": false,
}
}
health["iniFile"] = map[string]interface{}{
"path": s.cfg.App.Playlists,
}
s.jsonResponse(w, http.StatusOK, health)
}
// handleAPIStats — API: статистика
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
playlists, _, err := s.getPlaylistsView()
if err != nil {
s.jsonError(w, http.StatusInternalServerError, err)
return
}
stats := computeStats(playlists)
s.jsonResponse(w, http.StatusOK, stats)
}
// renderNotFound — отображает страницу 404
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
w.WriteHeader(http.StatusNotFound)
data := &PageData{
Title: s.cfg.Site.Header.Title,
BaseUrl: s.cfg.Site.BaseUrl,
RepoUrl: s.cfg.Site.RepoUrl,
Version: app.VERSION,
Meta: s.cfg.Site.Meta,
Menu: s.cfg.Site.Header.Menu,
FooterLinks: s.cfg.Site.Footer.Links,
Tabs: s.cfg.Site.Tabs,
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
}
+444
View File
@@ -0,0 +1,444 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app"
"embed"
"encoding/json"
"io/fs"
"net/http"
)
//go:embed swagger
var swaggerFS embed.FS
//go:embed static
var staticFS embed.FS
// handleOpenAPISpec — отдаёт динамически сгенерированную OpenAPI 3.0 спецификацию
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
spec := s.generateOpenAPISpec()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(spec)
}
// swaggerFileServer раздаёт встроенные файлы Swagger UI (CSS, JS, HTML)
var swaggerFileServer http.Handler
// staticFileServer раздаёт встроенные статические файлы (JS, CSS)
var staticFileServer http.Handler
func init() {
sub, err := fs.Sub(swaggerFS, "swagger")
if err != nil {
panic("cannot create sub-FS for swagger: " + err.Error())
}
swaggerFileServer = http.FileServer(http.FS(sub))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("cannot create sub-FS for static: " + err.Error())
}
staticFileServer = http.FileServer(http.FS(staticSub))
}
// generateOpenAPISpec строит OpenAPI 3.0.3 спецификацию динамически
func (s *Server) generateOpenAPISpec() map[string]interface{} {
return map[string]interface{}{
"openapi": "3.0.3",
"info": map[string]interface{}{
"title": s.cfg.Site.Header.Title + " API",
"version": app.VERSION,
"description": "API для проверки IPTV-плейлистов и доступности каналов.",
"license": map[string]interface{}{
"name": "MIT",
"url": "https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE",
},
},
"servers": []map[string]interface{}{
{
"url": s.cfg.Site.BaseUrl,
"description": "Текущий сервер",
},
},
"paths": map[string]interface{}{
"/api/playlists": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Список плейлистов",
"description": "Возвращает список всех плейлистов из ini-файла с результатами проверки.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Список плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistListResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Информация о плейлисте",
"description": "Возвращает информацию о плейлисте по его коду, включая группы, атрибуты и результаты проверки.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о плейлисте",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/playlists/{code}/channels": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Playlists"},
"summary": "Каналы плейлиста",
"description": "Возвращает каналы плейлиста по его коду.",
"parameters": []map[string]interface{}{
{
"name": "code",
"in": "path",
"required": true,
"description": "Код плейлиста (из ini-файла)",
"schema": map[string]interface{}{"type": "string"},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Каналы плейлиста",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ChannelListResponse"},
},
},
},
"400": map[string]interface{}{
"description": "Код плейлиста не передан",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
"404": map[string]interface{}{
"description": "Плейлист не найден",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
"/api/version": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Версии компонентов",
"description": "Возвращает версию iptvc и (при наличии) версию KeyDB/Redis.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Информация о версиях",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/VersionResponse"},
},
},
},
},
},
},
"/api/health": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"System"},
"summary": "Состояние сервиса",
"description": "Возвращает состояние подключения к KeyDB/Redis и путь к ini-файлу.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Состояние сервиса",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/HealthResponse"},
},
},
},
},
},
},
"/api/stats": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"Statistics"},
"summary": "Статистика",
"description": "Возвращает агрегированную статистику по плейлистам и каналам.",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Статистика",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/StatsResponse"},
},
},
},
"500": map[string]interface{}{
"description": "Ошибка загрузки плейлистов",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
},
},
},
},
},
},
},
"components": map[string]interface{}{
"schemas": map[string]interface{}{
"PlaylistListResponse": map[string]interface{}{
"type": "object",
"description": "Список плейлистов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Playlist"},
"description": "Массив плейлистов",
},
},
},
"PlaylistResponse": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте с результатами проверки",
"allOf": []map[string]interface{}{
{"$ref": "#/components/schemas/Playlist"},
},
},
"Playlist": map[string]interface{}{
"type": "object",
"description": "Информация о плейлисте",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string", "description": "Код плейлиста"},
"name": map[string]interface{}{"type": "string", "description": "Название плейлиста"},
"description": map[string]interface{}{"type": "string", "description": "Описание плейлиста"},
"url": map[string]interface{}{"type": "string", "description": "URL плейлиста"},
"source": map[string]interface{}{"type": "string", "description": "Источник плейлиста"},
"isOnline": map[string]interface{}{
"type": "boolean",
"nullable": true,
"description": "Доступность плейлиста (null если не проверялся)",
},
"onlineCount": map[string]interface{}{"type": "integer", "description": "Количество рабочих каналов"},
"offlineCount": map[string]interface{}{"type": "integer", "description": "Количество нерабочих каналов"},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp последней проверки"},
"content": map[string]interface{}{
"type": "string",
"description": "Содержимое плейлиста (только для оффлайн-плейлистов)",
},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTM3U",
},
"groups": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Group"},
"description": "Группы каналов",
},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги плейлиста",
},
},
},
"ChannelListResponse": map[string]interface{}{
"type": "object",
"description": "Список каналов плейлиста",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"items": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Channel"},
"description": "Массив каналов",
},
},
},
"Channel": map[string]interface{}{
"type": "object",
"description": "Информация о канале и статусе его проверки",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш URL канала"},
"title": map[string]interface{}{"type": "string", "description": "Название канала"},
"url": map[string]interface{}{"type": "string", "description": "URL потока канала"},
"groupId": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"hasToken": map[string]interface{}{"type": "boolean", "description": "Признак наличия токена/ключа в URL"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTINF",
},
"status": map[string]interface{}{"type": "integer", "description": "HTTP-код статуса"},
"isOnline": map[string]interface{}{"type": "boolean", "description": "Доступность канала (status < 400)"},
"error": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
"contentType": map[string]interface{}{"type": "string", "description": "MIME-тип ответа"},
"tags": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},
"description": "Теги канала",
},
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp проверки"},
},
},
"Group": map[string]interface{}{
"type": "object",
"description": "Группа каналов",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
"name": map[string]interface{}{"type": "string", "description": "Название группы"},
"attributes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
"description": "Атрибуты тега #EXTGRP",
},
},
},
"Attribute": map[string]interface{}{
"type": "object",
"description": "Именованный атрибут тега",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "Имя атрибута"},
"value": map[string]interface{}{"type": "string", "description": "Значение атрибута"},
},
},
"VersionResponse": map[string]interface{}{
"type": "object",
"description": "Информация о версиях компонентов",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"iptvc": map[string]interface{}{"type": "string", "description": "Версия iptvc"},
"keydb": map[string]interface{}{
"type": "string",
"description": "Версия KeyDB/Redis (если подключён)",
},
},
},
"HealthResponse": map[string]interface{}{
"type": "object",
"description": "Состояние сервиса",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"redis": map[string]interface{}{
"type": "object",
"description": "Состояние подключения к KeyDB/Redis",
"properties": map[string]interface{}{
"isConnected": map[string]interface{}{"type": "boolean", "description": "Подключение установлено"},
},
},
"iniFile": map[string]interface{}{
"type": "object",
"description": "Информация об ini-файле",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Путь к playlists.ini"},
},
},
},
},
"StatsResponse": map[string]interface{}{
"type": "object",
"description": "Агрегированная статистика",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"playlists": map[string]interface{}{
"type": "object",
"description": "Статистика по плейлистам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"unknown": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
"hasCatchup": map[string]interface{}{"type": "integer"},
"hasTvg": map[string]interface{}{"type": "integer"},
"groupped": map[string]interface{}{"type": "integer"},
"latest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"code": map[string]interface{}{"type": "string"},
"time": map[string]interface{}{"type": "integer"},
"timeFmt": map[string]interface{}{"type": "string"},
},
},
},
},
"channels": map[string]interface{}{
"type": "object",
"description": "Статистика по каналам",
"properties": map[string]interface{}{
"all": map[string]interface{}{"type": "integer"},
"online": map[string]interface{}{"type": "integer"},
"offline": map[string]interface{}{"type": "integer"},
"adult": map[string]interface{}{"type": "integer"},
},
},
},
},
"ErrorResponse": map[string]interface{}{
"type": "object",
"description": "Ошибка",
"properties": map[string]interface{}{
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
"error": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
},
},
},
},
},
},
}
}
+171
View File
@@ -0,0 +1,171 @@
package web
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// TestOpenAPISpec проверяет, что /api/openapi.json отдаёт валидную спецификацию
func TestOpenAPISpec(t *testing.T) {
ts, _ := makeTestServer(t)
resp, err := http.Get(ts.URL + "/api/openapi.json")
if err != nil {
t.Fatalf("GET /api/openapi.json: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("expected application/json content-type, got %s", ct)
}
var spec map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
t.Fatalf("cannot decode spec: %s", err)
}
// базовые поля OpenAPI 3.0
if v, ok := spec["openapi"].(string); !ok || !strings.HasPrefix(v, "3.0") {
t.Errorf("expected openapi 3.0.x, got %v", spec["openapi"])
}
info, ok := spec["info"].(map[string]interface{})
if !ok {
t.Fatal("missing 'info' section")
}
if _, ok := info["title"].(string); !ok {
t.Error("missing info.title")
}
if _, ok := info["version"].(string); !ok {
t.Error("missing info.version")
}
// сервер должен быть указан
servers, ok := spec["servers"].([]interface{})
if !ok || len(servers) == 0 {
t.Error("expected at least one server")
}
// пути
paths, ok := spec["paths"].(map[string]interface{})
if !ok {
t.Fatal("missing 'paths' section")
}
expectedPaths := []string{
"/api/playlists",
"/api/playlists/{code}",
"/api/playlists/{code}/channels",
"/api/version",
"/api/health",
"/api/stats",
}
for _, p := range expectedPaths {
if _, ok := paths[p]; !ok {
t.Errorf("missing path %s in spec", p)
}
}
// компоненты/схемы
components, ok := spec["components"].(map[string]interface{})
if !ok {
t.Fatal("missing 'components' section")
}
schemas, ok := components["schemas"].(map[string]interface{})
if !ok {
t.Fatal("missing 'components.schemas' section")
}
expectedSchemas := []string{
"PlaylistListResponse",
"PlaylistResponse",
"Playlist",
"ChannelListResponse",
"Channel",
"Group",
"Attribute",
"VersionResponse",
"HealthResponse",
"StatsResponse",
"ErrorResponse",
}
for _, s := range expectedSchemas {
if _, ok := schemas[s]; !ok {
t.Errorf("missing schema %s in components.schemas", s)
}
}
}
// TestSwaggerUI проверяет, что /api/ отдаёт HTML-страницу Swagger UI
// и что CSS/JS-ассеты доступны без внешних зависимостей
func TestSwaggerUI(t *testing.T) {
ts, _ := makeTestServer(t)
// /api → редирект на /api/
noRedirect := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := noRedirect.Get(ts.URL + "/api")
if err != nil {
t.Fatalf("GET /api: %s", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("expected 301 redirect, got %d", resp.StatusCode)
}
// /api/ → HTML-страница
resp, err = http.Get(ts.URL + "/api/")
if err != nil {
t.Fatalf("GET /api/: %s", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("expected text/html content-type, got %s", ct)
}
body := fetchBody(t, resp)
if !strings.Contains(body, "swagger-ui") {
t.Error("Swagger UI page should contain 'swagger-ui' reference")
}
if !strings.Contains(body, "/api/openapi.json") {
t.Error("Swagger UI page should reference /api/openapi.json")
}
// проверяем, что нет ссылок на CDN
if strings.Contains(body, "cdn.jsdelivr.net") || strings.Contains(body, "unpkg.com") {
t.Error("Swagger UI page should not reference any CDN")
}
// CSS-ассет
respCSS, err := http.Get(ts.URL + "/api/swagger-ui.css")
if err != nil {
t.Fatalf("GET /api/swagger-ui.css: %s", err)
}
defer respCSS.Body.Close()
if respCSS.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for CSS, got %d", respCSS.StatusCode)
}
if ct := respCSS.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Errorf("expected text/css content-type, got %s", ct)
}
// JS-ассет
respJS, err := http.Get(ts.URL + "/api/swagger-ui-bundle.js")
if err != nil {
t.Fatalf("GET /api/swagger-ui-bundle.js: %s", err)
}
defer respJS.Body.Close()
if respJS.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for JS, got %d", respJS.StatusCode)
}
if ct := respJS.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("expected javascript content-type, got %s", ct)
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app/checker"
"axenov/iptv-checker/app/config"
"axenov/iptv-checker/app/inifile"
"axenov/iptv-checker/app/playlist"
"axenov/iptv-checker/app/utils"
"log"
"maps"
"net/http"
"os"
"slices"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Server — веб-сервер для отображения плейлистов
type Server struct {
cfg *config.Config
cache *redis.Client
templates *TemplateManager
iniCache inifile.IniFile
iniLoaded time.Time
memMu sync.RWMutex
memCache map[string]playlist.Playlist
}
// NewServer создаёт новый экземпляр веб-сервера
func NewServer(cfg *config.Config, cache *redis.Client) *Server {
s := &Server{
cfg: cfg,
cache: cache,
memCache: make(map[string]playlist.Playlist),
}
tm, err := NewTemplateManager(cfg)
if err != nil {
log.Fatalf("Failed to load templates: %s", err)
}
s.templates = tm
return s
}
// Start запускает веб-сервер
func (s *Server) Start() {
mux := http.NewServeMux()
// API routes (specific, no conflicts)
mux.HandleFunc("GET /api/playlists", s.handleAPIGetAll)
mux.HandleFunc("GET /api/playlists/{code}", s.handleAPIGetOne)
mux.HandleFunc("GET /api/playlists/{code}/channels", s.handleAPIGetChannels)
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
// Static files
mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer))
// OpenAPI spec + Swagger UI
mux.HandleFunc("GET /api/openapi.json", s.handleOpenAPISpec)
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
})
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
// All other routes handled by catch-all to avoid mux pattern conflicts
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /{path...}", s.handleRoute)
handler := s.withMiddleware(mux)
addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10)
log.Printf("Starting web server on %s", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("Server failed: %s", err)
}
}
// withMiddleware добавляет middleware: логирование, восстановление после паники
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr)
})
}
// loadIni загружает ini-файл (с кешированием на 30 секунд)
func (s *Server) loadIni() (inifile.IniFile, error) {
// перезагружаем раз в 30 секунд
if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 {
return s.iniCache, nil
}
ini, err := inifile.Init(s.cfg.App.Playlists)
if err != nil {
return inifile.IniFile{}, err
}
s.iniCache = ini
s.iniLoaded = time.Now()
return ini, nil
}
// getUpdatedAt возвращает время последнего изменения ini-файла
func (s *Server) getUpdatedAt() string {
path, err := utils.ExpandPath(s.cfg.App.Playlists)
if err != nil {
return ""
}
info, err := os.Stat(path)
if err != nil {
return ""
}
loc, err := time.LoadLocation(s.cfg.App.Timezone)
if err != nil {
loc = time.UTC
}
return info.ModTime().In(loc).Format("02.01.2006 15:04")
}
// getTimezoneLabel возвращает сокращённое обозначение часового пояса для отображения.
func (s *Server) getTimezoneLabel() string {
return s.cfg.App.Timezone
}
// CheckOptions — параметры фоновой проверки плейлистов
type CheckOptions struct {
Repeat uint
Random uint
Files []string
Urls []string
Codes []string
}
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
// каждый проверенный плейлист сразу попадает в in-memory кеш
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
s.memMu.Lock()
s.memCache[pls.Code] = pls
s.memMu.Unlock()
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
s.iniLoaded = time.Time{}
}
// AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
cooldown := time.Duration(s.cfg.Check.Playlists.AllCooldown.Value()) * time.Millisecond
log.Printf("Background checker started: cooldown=%s repeat=%d", cooldown, opts.Repeat)
iteration := uint(0)
for {
iteration++
log.Printf("@ Background checker iteration=%d repeat=%d", iteration, opts.Repeat)
s.runCheckerOnce(opts)
if opts.Repeat > 0 && iteration >= opts.Repeat {
log.Println("Background checker finished: reached repeat count")
return
}
log.Printf("Background checker waiting... cooldown=%s", cooldown)
// cooldown уже вычислен в миллисекундах выше
time.Sleep(cooldown)
}
}
// runCheckerOnce выполняет один цикл проверки плейлистов
func (s *Server) runCheckerOnce(opts CheckOptions) {
defer func() {
if err := recover(); err != nil {
log.Printf("Background checker panic: %v", err)
}
}()
var lists []playlist.Playlist
if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 {
// проверка конкретных файлов/URL/кодов
lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes)
} else {
// проверка всех плейлистов из ini
ini, err := inifile.Init(s.cfg.App.Playlists)
if err != nil {
log.Printf("Background checker: cannot load ini: %s", err)
return
}
lists = slices.Collect(maps.Values(ini.Lists))
}
if len(lists) == 0 {
log.Println("Background checker: no playlists to check")
return
}
log.Printf("Background checker: %d playlists will be checked", len(lists))
startTime := time.Now()
onlineCount, offlineCount := checker.CheckPlaylists(lists)
log.Printf(
"Background checker done! online=%d offline=%d elapsed=%.2fs",
onlineCount,
offlineCount,
time.Since(startTime).Seconds(),
)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swagger UI — IPTV Checker API</title>
<link rel="stylesheet" href="./swagger-ui.css">
<style>
body { margin: 0 }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"></script>
<script src="./swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {
window.ui = SwaggerUIBundle({
url: window.location.origin + '/api/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'BaseLayout',
});
};
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+176
View File
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app/config"
"bytes"
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
"strconv"
"time"
)
//go:embed views/*.html
var viewsFS embed.FS
// TemplateManager управляет загрузкой и рендерингом шаблонов
type TemplateManager struct {
templates map[string]*template.Template
funcMap template.FuncMap
}
// NewTemplateManager создаёт новый менеджер шаблонов
func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) {
funcMap := template.FuncMap{
"baseUrl": func(path string) string {
base := cfg.Site.BaseUrl
if path == "" {
return base
}
return base + "/" + path
},
"formatDate": formatDate,
"formatTimeAgo": formatTimeAgo,
"statusClass": func(v PlaylistView) string {
return statusClass(&v)
},
"statusBadge": func(v PlaylistView) string {
return statusBadge(&v)
},
"jsonChannels": func(channels map[string]ChannelView) template.JS {
arr := make([]ChannelView, 0, len(channels))
for _, ch := range channels {
arr = append(arr, ch)
}
b, _ := json.Marshal(arr)
return template.JS(b)
},
"seq": func(start, end int) []int {
result := make([]int, 0, end-start+1)
for i := start; i <= end; i++ {
result = append(result, i)
}
return result
},
"sub": func(a, b int) int {
return a - b
},
"add": func(a, b int) int {
return a + b
},
"channelCount": func(v PlaylistView) int {
return len(v.Channels)
},
"groupCount": func(v PlaylistView) int {
return len(v.Groups)
},
"hasTag": containsTag,
}
tm := &TemplateManager{
templates: make(map[string]*template.Template),
funcMap: funcMap,
}
// parse base template
baseContent, err := viewsFS.ReadFile("views/base.html")
if err != nil {
return nil, fmt.Errorf("cannot read base.html: %w", err)
}
pages := []string{"list", "details", "notfound"}
for _, page := range pages {
pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page))
if err != nil {
return nil, fmt.Errorf("cannot read %s.html: %w", page, err)
}
tmpl := template.New("base").Funcs(funcMap)
tmpl, err = tmpl.Parse(string(baseContent))
if err != nil {
return nil, fmt.Errorf("cannot parse base template: %w", err)
}
tmpl, err = tmpl.Parse(string(pageContent))
if err != nil {
return nil, fmt.Errorf("cannot parse %s template: %w", page, err)
}
tm.templates[page] = tmpl
}
return tm, nil
}
// Render рендерит шаблон страницы
func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) {
tmpl, ok := tm.templates[page]
if !ok {
http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
log.Printf("Template error (%s): %s", page, err)
http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
// formatTimeAgo возвращает относительное время на русском
func formatTimeAgo(ts int64) string {
if ts == 0 {
return "неизвестно"
}
diff := time.Now().Unix() - ts
if diff < 0 {
diff = 0
}
if diff < 60 {
return "только что"
}
if diff < 3600 {
minutes := diff / 60
return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад"
}
if diff < 86400 {
hours := diff / 3600
return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад"
}
if diff < 604800 {
days := diff / 86400
return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад"
}
return formatDate(ts)
}
// getNounForm возвращает правильную форму существительного для числительного
func getNounForm(number int, form1, form2, form5 string) string {
abs := int(math.Abs(float64(number)))
lastDigit := abs % 10
lastTwoDigits := abs % 100
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
return form5
}
if lastDigit == 1 {
return form1
}
if lastDigit >= 2 && lastDigit <= 4 {
return form2
}
return form5
}
+80
View File
@@ -0,0 +1,80 @@
package web
import (
"testing"
"time"
)
func TestGetNounForm(t *testing.T) {
cases := []struct {
n int
f1 string
f2 string
f5 string
want string
}{
{1, "минуту", "минуты", "минут", "минуту"},
{2, "минуту", "минуты", "минут", "минуты"},
{5, "минуту", "минуты", "минут", "минут"},
{11, "минуту", "минуты", "минут", "минут"},
{21, "минуту", "минуты", "минут", "минуту"},
{22, "минуту", "минуты", "минут", "минуты"},
{25, "минуту", "минуты", "минут", "минут"},
{101, "минуту", "минуты", "минут", "минуту"},
{111, "минуту", "минуты", "минут", "минут"},
{0, "минуту", "минуты", "минут", "минут"},
}
for _, c := range cases {
got := getNounForm(c.n, c.f1, c.f2, c.f5)
if got != c.want {
t.Errorf("getNounForm(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestFormatTimeAgo_Zero(t *testing.T) {
if formatTimeAgo(0) != "неизвестно" {
t.Errorf("expected 'неизвестно' for ts=0")
}
}
func TestFormatTimeAgo_JustNow(t *testing.T) {
now := time.Now().Unix()
got := formatTimeAgo(now)
if got != "только что" {
t.Errorf("expected 'только что', got '%s'", got)
}
}
func TestFormatTimeAgo_Minutes(t *testing.T) {
ts := time.Now().Unix() - 120 // 2 minutes ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected minutes format, got '%s'", got)
}
}
func TestFormatTimeAgo_Hours(t *testing.T) {
ts := time.Now().Unix() - 7200 // 2 hours ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected hours format, got '%s'", got)
}
}
func TestFormatTimeAgo_Days(t *testing.T) {
ts := time.Now().Unix() - 172800 // 2 days ago
got := formatTimeAgo(ts)
if got == "неизвестно" || got == "только что" {
t.Errorf("expected days format, got '%s'", got)
}
}
func TestFormatTimeAgo_Future(t *testing.T) {
ts := time.Now().Unix() + 100
got := formatTimeAgo(ts)
// future → diff clamped to 0 → "только что"
if got != "только что" {
t.Errorf("expected 'только что' for future, got '%s'", got)
}
}
+325
View File
@@ -0,0 +1,325 @@
/*
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
package web
import (
"axenov/iptv-checker/app/config"
"axenov/iptv-checker/app/playlist"
"context"
"encoding/json"
"math"
"regexp"
"sort"
"strings"
"time"
)
// PlaylistView — расширенное представление плейлиста для шаблонов
type PlaylistView struct {
playlist.Playlist
IsKnown bool `json:"-"` // true если есть данные в кеше
OnlinePercent int `json:"-"`
OfflinePercent int `json:"-"`
HasCatchup bool `json:"-"`
HasTvg bool `json:"-"`
HasTokens bool `json:"-"`
Tags []string `json:"-"`
ViewChannels map[string]ChannelView `json:"-"`
}
// ChannelView — расширенное представление канала
type ChannelView struct {
playlist.Channel
HasToken bool `json:"hasToken"`
}
// PageData — данные для рендеринга шаблонов
type PageData struct {
Title string
BaseUrl string
RepoUrl string
Version string
UpdatedAt string
UpdatedAtTZ string
Meta config.MetaConfig
Menu []config.Link
FooterLinks []config.Link
Tabs config.TabsConfig
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")
}
+141
View File
@@ -0,0 +1,141 @@
{{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" .}}{{.Meta.Description}}{{end}}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="{{block "metakeywords" .}}{{.Meta.Keywords}}{{end}}">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/materialdesignicons.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 .Menu}}
{{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}}<span class="mdi mdi-{{.Icon}} me-1"></span>&nbsp;{{end}}{{.Title}}
</a>
<ul class="dropdown-menu dropdown-menu-dark">
{{range .Children}}
<li>
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
{{if .Icon}}<span class="mdi mdi-{{.Icon}}"></span>&nbsp;{{end}}{{.Title}}
</a>
</li>
{{end}}
</ul>
</li>
{{else}}
<li class="nav-item">
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
{{if .Icon}}<span class="mdi mdi-{{.Icon}} me-1"></span>&nbsp;{{end}}{{.Title}}
</a>
</li>
{{end}}
{{end}}
</ul>
</div>
</nav>
</header>
<main class="flex-grow-1 container py-4">
{{block "header" .}}{{end}}
<div class="content-wrapper">
{{block "content" .}}{{end}}
</div>
</main>
<footer class="bg-dark border-top border-secondary py-4">
<div class="container text-center">
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
{{range .FooterLinks}}
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
{{if .Icon}}<span class="mdi mdi-{{.Icon}}"></span>&nbsp;{{end}}{{.Title}}
</a>
{{end}}
</div>
<div class="small text-secondary d-inline-flex align-items-center gap-1">
<a class="text-secondary" href="{{.RepoUrl}}/releases/tag/v{{.Version}}" target="_blank">
<span class="mdi mdi-tag-outline"></span>&nbsp;iptvc&nbsp;v{{.Version}}
</a>&nbsp;Антон Аксенов &copy; 2025-2026 MIT&nbsp;License
</div>
</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
<div class="d-flex">
<div class="toast-body" id="clipboardToastBody"></div>
</div>
</div>
</div>
<script src="/static/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}}
+545
View File
@@ -0,0 +1,545 @@
{{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}
#qrContainer > img {margin: 0 auto;border: 10px solid white}
</style>
<script>
const DEFAULT_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAYAAACOEfKtAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9TpSoVBzuoOGSoTnZREcdahSJUCLVCqw4mL/2DJg1Jiouj4Fpw8Gex6uDirKuDqyAI/oA4OzgpukiJ9yWFFjE+eLyP8+45vHcvIDQqTLO64oCm22Y6mRCzuVUx9IpeDCMAICQzy5iTpBR819e9W4m7GM/yr/tz9at5iwEBkTjODNMm3iCe2bQNzvvEEVaSVeJz4gmTHkj8yHXF4zfORZcFnhkxM+l54gixWOxgpYNZydSIp4mjqqZTvpD1WOW8xVmr1FjrnfyH4by+ssx12qNIYhFLkCBCQQ1lVGAjRqdOioU03Sd8/COuXyKXQq4yGDkWUIUG2fWDz+B3b63C1KSXFE4A3S+O8zFG49kFmnXH+T52nOYJEHwGrvS2v9oAZj9Jr7e16BEwsA1cXLc1ZQ+43AGGngzZlF0pSFsoFID3MxpTDhi8BfrWvL617nH6AGSoV6kb4OAQGC9S9rrPv3s6+/ZvTat/PwcPcnsgbSAgAAAABmJLR0QAIQAlACl51qGsAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAB3RJTUUH6AkZAQARfbAD7gAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAYPSURBVHja7VtrVxpJFKxqQDG4uqLBV5LdPWf//2/ajRpB4xMREOi7H6jOdiYgIKBM0nWOR5Hp6Z7quo++3QMkJCQkJCQkJCQkJCQkJCQkJCTkBpynsZm9A/DbvPd5QxiAJsnHl96gOAd5BLCln1yLyMzaJO1VCcwo+B7AY86Ie6fJn8t6igsaTJvkfa5s938LmgsuhYH5UMyJWgoAKhpvH0CL5CAROB156wCOAJSifz+Z2RnJ3ltbn8uB8g5FXgfADYA2gDUAH8ys+ML7lgHs6GPnpRH4TRQo510B0J1CQZsiq0Pys9o7AMcANnSfuxn7LwH4qOj7QPImNwrUw+/LJI9lns9hTb+/pUgkvdQ4swCi/gmgBaCeNxPej1KHNQAfJ5AYFLqeUXAgdtZAcqj8zwNoaDJWn0AzK5jZgZZ9fQDnUoADcPgMiQ8isWJmR2ZWFQkVkdea1m2Y2Z7a9QCckOznKYjUpDwPoE6yKfPpREos/7DMGT5kXWRtAtjT7wGAWaLwpoKGAfhKspuLNEZR9H2kvEZYuJMcmNlppKgDMzvPPhzJtpmdiISQBzaz5Mm/7WiS7oJ5mlkFwIEuu9Lk5SYPrIm8gZT3mCHHm1ldUbWs1OSMZCdz3ROA6wnBIUwEAKyZ2YXSn5qCxg3J60U/oFuizzscpbwRZjoAcCJ/VpAS12foy0nlwS96ANsy9wOR+JidADMrmdl7M9tYRR8Ym22d5MOzJZ1hItuIkuSjUT7xGeVti7hz+Uwvcy7Lz56PWPrVdM22IvvbExgpb0tqaExbrFTAOJUSSxOicyCvJuX1AZyqr5aiNyL1D+J2ZrYfmTtXSYGx8s5JtmZpHCnxUSSOVKLIO8hE9uA3dzSGkOtlI+7vUuxK+UAXKc/PorwxSjyLSPxOiZHyNjVRJ6EvM9sEUNWlF9kJ1BbEblgDrxKBYdZ7AL7MqrwxSqxnzLkcLcW2sgoTye/1TLcAmiPIO5TJfsWwir4yBBaVpF7Ms0EzQolfosByDOBDlBadhIlSgeBYZD+QvIwrLKra1BTlH1XVsVUisKeVQWuRDlUknEcpTjnyed1obVzTJLblQ0eVxML6eSNS6soQeLMo5T2jxNhn9TMFgkpE7CBTeNgXaT1FeVMgqa4SgYYlQko8yxYgVFwIwWTU2rgaJdhhJdTQeAt5KiYsgsSBzDkUID5FEfWKZDtjutv6niKvrfs0IyX+OgRGxdQz+TpG5N2NiLh7Iuky65tF5tUvR2CEYH73I9a4RSXZBaUyt2PusZBdvWKeWBM5IaJ2pL44XSnp+6L85cU8G0Y/FYFR1WUDQFdBI6uiPaU6XZHnlz2uvGysU6lHSKIvRqQre5k1cO81xrYoAjfMbJmmsi4CLY6oEbb1fUDZzNYmjXkVCAykvcYxt7CfMWq1U42icig2vEoO+2ICSZqZ3WsAr3HAsovxm+jX8n0vmZTmsgNNQkJCQkJCQkJCQkJCQsLPhKmLAN77DwB6zrlv+64qcv5tZmfOuVZ07Q6G5aUigCcAV865B+/9McnKDyt6syfn3D9j+t0luZu5vuGcuxvTXx/AnXPuOh47yXeZW/9Lsjup7SQsvKDqva9qQA2RVwFw5L3/jOGuGs2sSPIPMzsl2Z1QDSmY2QPJhsj7K9PfDoBdkg0z62BYO9z33hecc5fRpfcAvn0mOZih7esRCGBHRyuCIm+991sAtpxzF3rooHw/xStbJQBP4TozQ6Z2u4vvj+72whE27/2Vcy4c9TXnXLavqdouksBtkRFMCSRjNZRIFjSA/YyreOmp+BLGnMb33hdJOqknNvEOhwXLsPm00LbzENgk+TXqjAD+jMwi/FnPnnM2s5k3eKSGNbmCqX14NA7O6v+nbPtiAn28WaMgEqMvotYWcRpe55dtnBKccz3v/QDD/Y12ph2ee51hnrZL84EkzXt/DWDXzPoAHqXSsoLF1O9neO+L8lGtCb7ohmTVe98T0esY7tDdTrGtOU/b5QQR59y1996b2Q6GmztGsqvXDmbBJ71LcjlNfyK7hOG25+00rzTM0zYhISEhISEhISEhISEht/gPtDdRP2psndcAAAAASUVORK5CYII='
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
function showQrCode(code) {
const url = '{{.BaseUrl}}/' + code;
const container = document.getElementById('qrContainer');
container.innerHTML = '';
new QRCode(container, {
text: url,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H,
});
const modal = new bootstrap.Modal(document.getElementById('qrModal'));
modal.show();
}
</script>
<script src="/static/js/qrcode.min.js"></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"
>
<span class="mdi mdi-television-classic"></span>&nbsp;Основные&nbsp;данные
</a>
</li>
{{if .Tabs.Raw.Visible}}
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-raw"
data-bs-toggle="tab"
data-bs-target="#tab-raw"
>
<span class="mdi mdi-text-long"></span>&nbsp;Исходный&nbsp;текст
</a>
</li>
{{end}}
{{if .Tabs.Legal.Visible}}
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-abuse"
data-bs-toggle="tab"
data-bs-target="#tab-abuse"
>
<span class="mdi mdi-wallet-outline"></span>&nbsp;Правообладателям
</a>
</li>
{{end}}
</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 class="w-25" scope="row">Описание</th>
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
</tr>
<tr>
<th class="w-25" 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>
<span class="mdi mdi-content-copy"></span>
</span>
</td>
</tr>
<tr>
<th class="w-25" scope="row">Источник</th>
<td class="text-break">{{$pls.Source}}</td>
</tr>
<tr>
<th class="w-25" 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">
<span class="mdi mdi-paw"></span>
</span>&nbsp;могут быть нестабильные каналы<br>
{{end}}
{{if hasTag $pls.Tags "adult"}}
<span class="cursor-help badge small bg-warning text-dark">18+</span>&nbsp;есть каналы для взрослых<br>
{{end}}
<span class="mdi mdi-folder-open-outline"></span>&nbsp;группы: {{groupCount $pls}}<br>
<span class="mdi mdi-video-outline"></span>&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}}
<span class="mdi mdi-newspaper-variant-outline"></span>&nbsp;Программа передач:&nbsp;{{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
<span class="mdi mdi-skip-backward"></span>&nbsp;Перемотка&nbsp;(архив):&nbsp;{{if $pls.HasCatchup}}есть{{else}}нет{{end}}
{{end}}
</td>
</tr>
<tr class="">
<th class="text-secondary w-25" scope="row">M3U</th>
<td class="text-secondary text-break">{{$pls.Url}}</td>
</tr>
<tr class="">
<th class="text-secondary w-25" scope="row">Проверка плейлиста</th>
<td class="text-secondary 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>
{{if .Tabs.Raw.Visible}}
<div class="tab-pane fade" id="tab-raw" tabindex="1">
<button class="btn btn-sm btn-success my-3"
id="saveM3UBtn"
onclick="savePlaylist()"
>
<span class="mdi mdi-download"></span>&nbsp;{{$pls.Code}}.m3u
</button>
<button type="button"
class="btn btn-sm btn-outline-light ms-2"
onclick="showQrCode('{{$pls.Code}}')"
title="Показать QR-код"
>
<span class="mdi mdi-qrcode-scan"></span>&nbsp;QR-код
</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>
{{end}}
{{if .Tabs.Legal.Visible}}
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
{{if .Tabs.Legal.Text}}
{{.Tabs.Legal.Text}}
{{else}}
<p class="my-3">
Данные, представленные на данной странице, получены автоматически из открыто доступных в
интернете IPTV-плейлистов, опубликованных третьими лицами.
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
данные".
</p>
<p class="my-3">
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
телепрограмм.
</p>
<p class="my-3">
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
формируется исключительно путём обработки содержимого самого плейлиста.
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
</p>
<p class="my-3">
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
</p>
{{end}}
</div>
{{end}}
</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="Сбросить группу"
>
<span class="mdi mdi-close"></span>
</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="Выбрать все каналы"
>
<span class="mdi mdi-radiobox-marked"></span>
</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="Выбрать только онлайн каналы"
>
<span class="mdi mdi-radiobox-marked"></span>{{$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="Выбрать только оффлайн каналы"
>
<span class="mdi mdi-radiobox-marked"></span>{{$pls.OfflineCount}}
</label>
<button type="button"
class="btn btn-sm btn-outline-secondary"
onclick="resetSearch()"
title="Сбросить фильтрацию"
>
<span class="mdi mdi-close"></span>
</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"}}
<div class="modal fade" id="qrModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">QR-код плейлиста</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<div id="qrContainer"></div>
</div>
</div>
</div>
</div>
{{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="Может быть нестабилен"><span class="mdi mdi-paw"></span></span>'
: ''
const tvgId = !!channel.attributes['tvg-id']
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><span class="mdi mdi-star-outline me-1"></span>&nbsp;${channel.attributes['tvg-id']}</div>`
: ``
const mimeType = !!channel.contentType
? `<div title="Тип контента (mime-type)" class="cursor-help"><span class="mdi mdi-eye-outline me-1"></span>&nbsp;${channel.contentType}</div>`
: ``
const tags = channel.tags.length > 0
? `<span class="mdi mdi-tag-outline cursor-help me-1" title="Теги"></span>&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">
<span class="mdi mdi-radiobox-marked cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></span>
${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}}.m3u";
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}}
+143
View File
@@ -0,0 +1,143 @@
{{define "title"}}{{.Title}}{{end}}
{{define "metadescription"}}{{.Meta.Description}}{{end}}
{{define "metakeywords"}}{{.Meta.Keywords}}{{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>
{{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}} {{.UpdatedAtTZ}}</div>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-danger" role="alert">
<span class="mdi mdi-alert-circle-outline me-1"></span>{{.Error}}
</div>
{{else if eq (len .Playlists) 0}}
<div class="alert alert-warning" role="alert">
<span class="mdi mdi-information-circle-outline me-1"></span>Список плейлистов пуст.
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
</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="В плейлисте есть каналы, которые могут быть нестабильны"
><span class="mdi mdi-paw"></span></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">
<span class="mdi mdi-video-outline me-1"></span>&nbsp;{{channelCount .}}<span class="d-none d-xl-inline-block">&nbsp;каналов</span>
</span>
{{if gt (groupCount .) 0}}
<span class="badge border border-secondary">
<span class="mdi mdi-folder-open-outline me-1"></span>&nbsp;{{groupCount .}}<span class="d-none d-xl-inline-block">&nbsp;групп</span>
</span>
{{end}}
{{if .HasTvg}}
<span class="badge border border-secondary">
<span class="mdi mdi-newspaper-variant-outline me-1"></span><span class="d-none d-xl-inline-block">&nbsp;ТВ-программа</span>
</span>
{{end}}
{{if .HasCatchup}}
<span class="badge border border-secondary">
<span class="mdi mdi-skip-backward me-1"></span><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>
<span class="mdi mdi-content-copy"></span>
</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">
<span class="mdi mdi-warning-outline display-1 text-warning mb-3"></span>
<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="На главную">
<span class="mdi mdi-list-outline me-1"></span>Перейти к списку плейлистов
</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"])
}
}